Chart
A bar chart in server-rendered SVG, wearing Recharts' class names so shadcn's container styles it.
src/components/rahti_ui/chart.rs
Your first chart
Bars, and nothing else yet.
The docs page's first step: two series, six categories, no grid and no axis. The colours come from the config — a `#[component]` cannot take a closure or a React node, so shadcn's six composed children are props here instead.
`height` is passed as well as the class, and both say 200. The surface is drawn in percentages horizontally and pixels vertically, so the height is the one number the server cannot work out for itself — the module docs argue why that beats a viewBox that would scale the labels with the box.
Because the height is known, the container is served with shadcn's own aspect-auto and the base string's aspect-video never survives. shadcn's version of this step keeps it, so its box is 16:9 and ResponsiveContainer fills whatever that comes to; here the surface is 200px tall and a 16:9 box around it would be 300px of nothing.
Add a grid
Horizontal only, at the y ticks.
shadcn passes CartesianGrid with vertical off, which is what `grid` defaults to here. The lines are the y ticks — five of them, at 0, 80, 160, 240 and 320, because Recharts rounds a 305 maximum up by twentieths of its own magnitude rather than along a 1/2/5 ladder. That arithmetic is ported and tested against the reference render.
Each line carries a literal stroke of #ccc, which is what shadcn's container selects on to repaint them as border/50. Take the attribute off and the selector stops matching.
Add an axis
One tick per category, centred on its band.
The axis is on by default and draws neither an axis line nor tick lines, because every shadcn demo turns both off and passes a tick margin of 10. Recharts' own defaults are the other way round; the demos won, the same call the Hover Card port made about its delays.
The labels read Jan, Feb, Mar because each row carries a `tick` beside its `label`. shadcn slices the string in a tickFormatter closure; there is nowhere to put a closure in markup, so the short form is computed on the server and the long one is what the tooltip prints.
Add a tooltip
The only part of this component that needs the browser.
Hover the chart. Every category is served as a hidden card and the script shows one — there is no runtime that could build a card on demand, and six cards of two rows is cheaper than the machinery that would avoid them.
The band behind the pointer is a rect with Recharts' own tooltip-cursor classes, drawn before the bars so shadcn's fill-muted sits behind them rather than over them. The card is placed 10px off the pointer and flips to the other side rather than leaving the box, which is Recharts' own rule.
Add a legend
One entry per series, named and coloured by the config.
The legend takes its 28px out of the plot rather than sitting over it, so the bars are shorter than they were a section ago. Pass legend_side of top to put it above the chart, which swaps its padding from pt-3 to pb-3.
Theming
Colours live in the config, and reach the SVG as custom properties.
The same chart against this application's own chart tokens rather than two hex colours. A config entry takes any CSS colour — hex, hsl, oklch, or a var — and the container publishes it as --color-desktop on a data-chart scope, which is what every fill in the SVG then references.
And a config with a theme rather than a colour: blue in light, orange in dark. That is the only thing that earns the second .dark block — shadcn emits one unconditionally, which for a plain colour writes the same declarations twice.
A colour is checked before it reaches the stylesheet. The block is written with Html::from_raw, because a stylesheet is not escapable text, so anything that is not plainly a CSS colour is dropped with a debug assertion rather than served.
Tooltip shapes
dot, line and dashed — shadcn's indicator prop.
The three indicator shapes, and the last one with its label hidden. Hover each. The shapes are shadcn's resolved class lists rather than its layers: a dot's row is written with items-center and no items-stretch, and a dashed swatch drops the background entirely, because that is what tailwind-merge produces and there is no merge in Rust to produce it here.
The cards, on their own
ChartTooltipContent and ChartLegendContent as tags.
The card on its own, which is what the docs page's Tooltip section shows. The third one nests its title inside the row — shadcn does that whenever there is exactly one row and the shape is not a dot, and it moves the reading onto the title's baseline as well.
And the legend row on its own. Both take a caller class through twMerge rather than a server join, unlike the container — they are only ever on screen once the runtime is there anyway.
Custom keys
label_key and name_key, for data that is not one series per column.
The docs page's other data shape: one reading called visitors, one row per browser, and the colour on the row rather than on the series. label_key points the card's title at the visitors entry, so it reads Total Visitors; name_key points each row's name at its own browser entry, so the rows read Chrome and Safari rather than visitors twice.
That is shadcn's getPayloadConfigFromPayload, ported: a string field on the row, found under the key, renames the config lookup — and a row's label counts as a field under the chart's x_key, because that is where a category lives here. With one series and a name_key the legend goes per row too.
Accessibility
Keyboard access and a name, which shadcn's accessibilityLayer stands for.
Tab to the chart and walk it with the left and right arrows; Escape puts the card away. That is Recharts' accessibilityLayer: the surface takes focus, announces itself through a title, and the arrow keys move the same active category the pointer would. Without a tooltip there is nothing to walk, so the surface is a plain image instead.
Give it an aria_label. An svg with role of application and no accessible name is worse than one with role of img, and shadcn's own markup leaves this undone — the same gap the Progress port had to fill.
Responsive without measuring
Percentages across, pixels down.
The same chart in a 16rem column and in whatever is left. Nothing measured anything: every horizontal length in the SVG is a percentage, so the bands divide the box wherever the box ends. The labels are the same size in both, which is the whole reason there is no viewBox — one would have scaled the text down with the narrow column.
It is also why the caller's class is joined on the server here rather than handed to twMerge. A merged list is literal text until PulsePoint mounts, and a chart that gets its text-xs late lays its axis out twice.
The port
What moved, what did not, and why.