Bar Chart
A bar chart for comparing values across categories.
Installation
Charts ship in the separate, optional poetry-charts
gem — not bundled with poetry-ui. If you
don't have it yet, add it and wire it into your app:
bundle add poetry-charts
bin/rails g poetry:install --charts
Default
<% data = [
{ month: "January", desktop: 186 },
{ month: "February", desktop: 305 },
{ month: "March", desktop: 237 },
{ month: "April", desktop: 73 },
{ month: "May", desktop: 209 },
{ month: "June", desktop: 214 }
]
config = { desktop: { label: "Desktop", color: "var(--chart-1)" } } %>
<div class="w-full max-w-xl">
<%= poetry_bar_chart(data: data, config: config, id: "bar-default") do |chart| %>
<% chart.with_grid %>
<% chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] }) %>
<% chart.with_bar(data_key: :desktop, radius: 8) %>
<% chart.with_tooltip(hide_label: true) %>
<% end %>
</div>
Active
<%# color_key: :fill reads each row's fill; active_index: highlights one
bar with the shadcn active look (fill-opacity + dashed stroke). %>
<% config = {
visitors: { label: "Visitors", color: "var(--chart-2)" },
chrome: { label: "Chrome", color: "var(--chart-1)" },
safari: { label: "Safari", color: "var(--chart-2)" },
firefox: { label: "Firefox", color: "var(--chart-3)" },
edge: { label: "Edge", color: "var(--chart-4)" },
other: { label: "Other", color: "var(--chart-5)" }
}
data = [
{ browser: "chrome", visitors: 275, fill: "var(--color-chrome)" },
{ browser: "safari", visitors: 200, fill: "var(--color-safari)" },
{ browser: "firefox", visitors: 187, fill: "var(--color-firefox)" },
{ browser: "edge", visitors: 173, fill: "var(--color-edge)" },
{ browser: "other", visitors: 90, fill: "var(--color-other)" }
] %>
<div class="w-full max-w-xl">
<%= poetry_bar_chart(data: data, config: config, id: "bar-active") do |chart| %>
<% chart.with_grid %>
<% chart.with_x_axis(data_key: :browser, tick_formatter: ->(v) { config[v.to_sym][:label] }) %>
<% chart.with_bar(data_key: :visitors, radius: 8, color_key: :fill, active_index: 2) %>
<% end %>
</div>
Horizontal
<%# orientation: :horizontal grows bars rightward - the category axis
moves to the Y side (with_y_axis data_key:) and the numeric axis
hides. Negative left margin tucks the ticks against the bars. %>
<% data = [
{ month: "January", desktop: 186 },
{ month: "February", desktop: 305 },
{ month: "March", desktop: 237 },
{ month: "April", desktop: 73 },
{ month: "May", desktop: 209 },
{ month: "June", desktop: 214 }
]
config = { desktop: { label: "Desktop", color: "var(--chart-1)" } } %>
<div class="w-full max-w-xl">
<%= poetry_bar_chart(data: data, config: config, id: "bar-horizontal",
orientation: :horizontal, margin: { left: -20 }) do |chart| %>
<% chart.with_y_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] }, tick_margin: 10) %>
<% chart.with_bar(data_key: :desktop, radius: 5) %>
<% end %>
</div>
Label
<%# labels: true prints each bar's value above it; margin top makes room
for the topmost label. %>
<% data = [
{ month: "January", desktop: 186 },
{ month: "February", desktop: 305 },
{ month: "March", desktop: 237 },
{ month: "April", desktop: 73 },
{ month: "May", desktop: 209 },
{ month: "June", desktop: 214 }
]
config = { desktop: { label: "Desktop", color: "var(--chart-1)" } } %>
<div class="w-full max-w-xl">
<%= poetry_bar_chart(data: data, config: config, id: "bar-label",
margin: { top: 20 }) do |chart| %>
<% chart.with_grid %>
<% chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] }) %>
<% chart.with_bar(data_key: :desktop, radius: 8, labels: true) %>
<% end %>
</div>
Mixed
<%# Horizontal bars, each colored from its row via color_key: :fill -
the shadcn "mixed" block. %>
<% config = {
visitors: { label: "Visitors", color: "var(--chart-2)" },
chrome: { label: "Chrome", color: "var(--chart-1)" },
safari: { label: "Safari", color: "var(--chart-2)" },
firefox: { label: "Firefox", color: "var(--chart-3)" },
edge: { label: "Edge", color: "var(--chart-4)" },
other: { label: "Other", color: "var(--chart-5)" }
}
data = [
{ browser: "chrome", visitors: 275, fill: "var(--color-chrome)" },
{ browser: "safari", visitors: 200, fill: "var(--color-safari)" },
{ browser: "firefox", visitors: 187, fill: "var(--color-firefox)" },
{ browser: "edge", visitors: 173, fill: "var(--color-edge)" },
{ browser: "other", visitors: 90, fill: "var(--color-other)" }
] %>
<div class="w-full max-w-xl">
<%= poetry_bar_chart(data: data, config: config, id: "bar-mixed",
orientation: :horizontal, margin: { left: 0 }) do |chart| %>
<% chart.with_y_axis(data_key: :browser, tick_margin: 10,
tick_formatter: ->(v) { config[v.to_sym][:label] }) %>
<% chart.with_bar(data_key: :visitors, radius: 5, color_key: :fill) %>
<% end %>
</div>
Multiple
<% data = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 }
]
config = { desktop: { label: "Desktop", color: "var(--chart-1)" },
mobile: { label: "Mobile", color: "var(--chart-2)" } } %>
<div class="w-full max-w-xl">
<%= poetry_bar_chart(data: data, config: config, id: "bar-multiple") do |chart| %>
<% chart.with_grid %>
<% chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] }) %>
<% chart.with_bar(data_key: :desktop, radius: 4) %>
<% chart.with_bar(data_key: :mobile, radius: 4) %>
<% end %>
</div>
Negative
<%# Negative values drop below the zero baseline; cell_fill colors each
bar by sign, and labels: true + label_key: writes the month above
(or below) each bar. %>
<% data = [
{ month: "January", visitors: 186 },
{ month: "February", visitors: 205 },
{ month: "March", visitors: -207 },
{ month: "April", visitors: 173 },
{ month: "May", visitors: -209 },
{ month: "June", visitors: 214 }
]
config = { visitors: { label: "Visitors" } } %>
<div class="w-full max-w-xl">
<%= poetry_bar_chart(data: data, config: config, id: "bar-negative") do |chart| %>
<% chart.with_grid %>
<% chart.with_bar(data_key: :visitors, labels: true, label_key: :month,
cell_fill: ->(_row, value) { value.positive? ? "var(--chart-1)" : "var(--chart-2)" }) %>
<% end %>
</div>
Stacked
<%# Stacked bars share a stack: id; the radius arrays round only the
outer edge - [0,0,4,4] for the bottom bar, [4,4,0,0] for the top. %>
<% data = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 }
]
config = { desktop: { label: "Desktop", color: "var(--chart-1)" },
mobile: { label: "Mobile", color: "var(--chart-2)" } } %>
<div class="w-full max-w-xl">
<%= poetry_bar_chart(data: data, config: config, id: "bar-stacked") do |chart| %>
<% chart.with_grid %>
<% chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] }) %>
<% chart.with_bar(data_key: :desktop, stack: :a, radius: [0, 0, 4, 4]) %>
<% chart.with_bar(data_key: :mobile, stack: :a, radius: [4, 4, 0, 0]) %>
<% chart.with_legend %>
<% end %>
</div>
API
Poetry::Charts::BarChart::Component — options are
constructor keywords (the poetry_* helper forwards them);
slots are composed inside the block. Generated from the gem's source documentation.
| Option | Type | Details | Description |
|---|---|---|---|
| bar_category_gap: | String | defaults to "10%" |
Band trim on each side: a percent string of the band width, or a bare pixel number. |
| bar_gap: | Integer | defaults to 4 |
Pixels between side-by-side bars inside one category band. |
| config: | Object | required | The series config - key => { label:, color: } - naming and coloring every series. |
| data: | Object | required | The rows to plot: an array of hashes, one per category. |
| height: | Integer | defaults to 360 |
ViewBox height in pixels. |
| id: | String | Explicit DOM id token, stable across renders; otherwise the chart gets a unique per-render id. | |
| label: | String | Accessible name for the chart SVG; defaults to one built from the configured series. | |
| margin: | Object | Plot margin overrides ({ top:, right:, bottom:, left: }), merged over the defaults. | |
| offset: | Symbol | one of: none, expand; defaults to :none |
Stack baseline mode - :expand normalizes each stack to percentages. |
| orientation: | Symbol | one of: vertical, horizontal; defaults to :vertical |
:vertical = columns (the default); :horizontal = bars growing rightward - the category axis moves to the Y side (with_y_axis data_key:) and the numeric axis hides. |
| width: | Integer | defaults to 640 |
ViewBox width in pixels; the rendered chart scales to its container. |
Slots
| Writer | Description |
|---|---|
| with_bar | A bar series bound to data_key:. Bars sharing a stack: id pile up; radius: rounds corners; labels:/label_key: stamp values; color_key:/cell_fill: color per cell; active_index: highlights one bar; error_key: adds whiskers. |
Methods
| Method | Description |
|---|---|
| #value_axis_slot | In the horizontal orientation the Y axis IS the category axis - give it the data_key. The CartesianFamily value-axis hook, so the include below declares this shape between x_axis and grid. |
Styling
Every part carries a stable data-slot attribute — target
[data-slot=…] from your own CSS to restyle it. State rides
data attributes on the parts below. This contract is verified against rendered DOM in CI.
| Part | Description |
|---|---|
| [data-slot=chart-brush] | The brush strip group (with_brush): track + window + two handles below the x axis |
| [data-slot=chart-brush-track] | The full-width brush rail |
| [data-slot=chart-brush-window] | The selected-range rect the drag moves |
| [data-slot=chart-brush-handle] | One draggable window edge |
| [data-slot=chart-zoom-selection] | The zoom drag-selection overlay (zoom: true), hidden until a drag starts |
| [data-slot=chart-live-payload] | The embedded {spec, frame} JSON the live renderer recomputes geometry from |
| [data-slot=chart-svg] | The chart canvas (<svg>) - server-computed geometry in a fixed viewBox; role=img, or the focusable role=application accessibilityLayer when the tooltip attaches |
| [data-slot=chart-grid] | The gridline group (with_grid) - horizontal and/or vertical rules across the plot |
| [data-slot=chart-cursor] | The hover cursor, hidden until the tooltip controller positions and reveals it at the active index - a vertical rule or a translucent band rect (bar charts) |
| [data-slot=chart-bars] | The bar-mark group wrapping every series |
| [data-slot=chart-bar-series] | One series' bar group |
| [data-slot=chart-bar] | One bar cell (a per-corner rounded-rect path) |
| [data-slot=chart-labels] | One series' value-label group (labels: true) |
| [data-slot=chart-x-axis] | The x-axis tick-label group (with_x_axis) |
| [data-slot=chart-y-axis] | The y-axis tick-label group (with_y_axis) |
| [data-slot=chart-coordinates] | The embedded per-index geometry payload (<script type=application/json>) the tooltip controller reads - zero chart math in the browser |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| chart-brush-handle | data-edge | always - which edge | start · end |
| chart-svg | data-animate | present when animate (the default) - the motion stylesheet and controller key the entrance off it | — |
| chart-svg | data-motion | runtime - the motion rig stamps the animation lifecycle (entrance/morph, then settled) | entrance · morph · settled |
| chart-bar-series | data-key | the series key | — |
| chart-bar | data-key | the series key | — |
| chart-bar | data-index | the datum index | — |
| chart-bar | data-active | the highlighted cell - server-rendered from active_index:, and the tooltip controller marks the hovered index at runtime | — |
| chart-bar | data-motion-origin | when animate - the zero edge the entrance grows from | bottom · top · left · right |
| chart-labels | data-key | the series key | — |
CSS variables
| Part | Variable | Description |
|---|---|---|
| chart-svg | --poetry-motion-duration | the entrance/morph duration (animation_duration, ms) |
| chart-svg | --poetry-motion-easing | the animation easing keyword (animation_easing) |
| chart-svg | --poetry-motion-delay | the pre-animation hold (animation_begin, ms) |
Wiring
The Stimulus surface each element carries — declared in the component, verified against rendered DOM in CI. Bare actions fire on the element's default event.
| Element | Controller | Wiring |
|---|---|---|
| frame | poetry--charts--tooltip (if tooltip?) | registers · value sync (if) |
| frame | poetry--charts--motion (if animate?) | registers |
| frame | poetry--charts--live (if live?) | registers · receive on poetry-chart:update |
| frame | poetry--charts--tooltip (if) | refresh on poetry--charts--live:updated |
| frame | poetry--charts--window (if window_features?) | registers · value zoom · value plot · value brush (if) |
| svg | poetry--charts--tooltip (if tooltip?) | move on pointermove · leave on pointerleave · focus on focus · blur on blur · keydown on keydown · target svg |
| svg | poetry--charts--window (if zoom?) | startZoom on pointerdown · reset on dblclick |
| coordinates | poetry--charts--tooltip (if tooltip?) | target data |
| tooltip_layer | poetry--charts--tooltip (if tooltip?) | target tooltip |
| live_payload | poetry--charts--live | target payload |
| brush | poetry--charts--window (if) | startBrush on pointerdown |