Tabs
A set of panels, one showing at a time — drawn by the server, switched by a script.
src/components/rahti_ui/tabs.rs
Basic
Two tabs and two panels, paired by `value`.
Account
Make changes to your account here. Click save when you are done.
Password
Change your password here. After saving you will be logged out.
shadcn's own demo. `defaultValue="account"` on the root became `active` on that trigger *and* its panel — the same divergence as the Accordion's `name`, and for the same reason: a component's children arrive already rendered, so a root cannot reach into them.
That is not just bookkeeping. `active` is what the *server* draws: the lit trigger, the one tab stop, and the `hidden` on the other panel are all in the first byte. The script's first act at mount is to change nothing.
`data-state` is real in this component, unlike in the Collapsible and the Accordion — the script keeps it true, so every one of shadcn's `data-[state=active]:` utilities crossed unchanged. All four class strings here are the registry's, verbatim.
Line
The same tabs with the pill dropped for an underline.
Overview
The pill is gone and an underline marks the active tab.
Activity
Nothing has happened yet.
Settings
Everything is at its default.
shadcn's second list variant. It is written twice on the list — as a class and as `data-variant="line"` — and the attribute is the load-bearing half: the underline lives on the *trigger*, behind `group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100`, so it reads the list through the group rather than through its own class list.
The underline is an `after:` pseudo-element that is always there and always transparent — the variant only turns its opacity up. That is why switching tabs fades it rather than redrawing it.
Vertical
The set with its axis turned, and everything that follows from it.
General
The triggers stack and the panels sit beside them.
Billing
Nothing is due.
Members
You are the only one here.
One prop on the root turns the whole set. `orientation="vertical"` writes `data-orientation` there, and the list and every trigger read it back through `group-data-[orientation=vertical]/tabs:` — the list becomes a column, the triggers go full-width and left-aligned, and the `after:` underline moves from the bottom edge to the right one.
The arrow keys follow it too: up and down here, left and right in a horizontal set, and flipped again inside `dir="rtl"`. That is the script reading the same attribute the CSS does.
The root's own `data-[orientation=horizontal]:flex-col` is written `data-horizontal:flex-col`, which is this project's short spelling from `globals.css`. The `group-` variants keep the registry's explicit form — composing a custom variant with `group-` is a different mechanism, and one nothing here has exercised.
Disabled
A tab that is drawn and cannot be selected.
Free
Everything you need to start.
Pro
For when the free plan stops being enough.
Team
Never reached.
A trigger is a real `<button>`, so `disabled` is a real `disabled` — the browser blocks the click and takes it out of the tab order without help, and shadcn's `disabled:pointer-events-none disabled:opacity-50` matches for the same reason. Compare the Accordion and the Collapsible, whose `<summary>` triggers had to imitate all of that.
The arrow keys skip it rather than landing on it, which is the WAI-ARIA pattern's own rule: arrow past `Pro` and focus goes back to `Free` rather than stopping on something that cannot be selected.
Manual activation
Arrow keys that move without selecting.
One
Focus a trigger and arrow across — nothing changes until Enter.
Two
You pressed Enter, or Space.
Three
Same again.
Radix's `activationMode`, with Radix's default. `automatic` — every other set on this page — selects a tab the moment an arrow key lands on it, which is the WAI-ARIA pattern's default and right when a panel is cheap.
`manual` moves the focus and waits. Reach for it when arrowing across the set would do real work each time — a panel that fetches, or one heavy enough that flicking through three of them is three renders nobody asked for.
Keyboard
The WAI-ARIA tabs pattern, which is most of what the script is for.
The roving `tabindex` is what makes the first row true: the selected trigger is `tabindex="0"` and every other is `-1`, so `Tab` enters the set once rather than walking every tab in it. The server writes the resting position and the script moves it from there.
The last row is Radix's, and it is the reason `CONTENT` carries `outline-none`: a panel is given `tabindex="0"` so that content holding nothing focusable can still be reached from the trigger — and a focus ring around a whole panel would be noise.
The `aria-controls` and `aria-labelledby` pair is the one thing on this page the server could not write. Both halves need an id and neither part can see the other, so the script derives one from `pp.id()` and wires them at mount.
From data
Tabs a page assembles rather than writes.
Routing
A page is `page.rs`; a layout is `layout.rs`.
Rendering
`html!` has one authored root, and server values use `@{…}`.
RPC
A page-owned `#[rpc]` lives beside its caller.
The list, the triggers and the panels built with `render` and `Html::concat` over a slice — which is also where `active` gets decided once, by index, rather than written out per tab. The script finds them by their `data-slot`, not by how they were made.
The root is the one part with no `render`, and that is not an oversight: a root *is* its script, a script written in a method's `html!` is a block of its own, and the `pp-ref` that script needs to find the root would never resolve there. There is one way to make a tab set, and it is the tag.
Nested
A tab set inside a panel, and the scoping that keeps them apart.
A second tab set, inside the first one's panel.
Archive
Empty.
Two scripts, and neither knows about the other. The outer set's script collects its parts by asking each candidate `closest("[data-slot=tabs]")` and keeping only the ones that answer with *its own* root — so the inner triggers and panels are invisible to it, and switching the outer tab does not disturb the inner one.
It is written as a filter rather than a `:scope >` walk for a second reason: it also sees through whatever wrapper the runtime raises around a fragment of triggers, which a direct-child selector would stop at.
The port
The road not taken, and what the script bought.
What is not ported: a controlled `value` / `onValueChange` pair on the root. The parts each carry their own `value` and the script sits between them; a page that needs to know which tab is showing can watch `data-state` on the panels rather than binding it, because the script owns that attribute and rewrites it on every selection.
The boundary
The class lists without the component, and what stops working.
No component in sight
The design is the class list; the behaviour is the script.
Write `data-orientation` on the root and `data-variant` on the list. Neither is decoration: the list's height and the trigger's underline are both behind `group-data-[…]` variants that read those attributes, so a set without them draws the wrong shape however complete its class list is.
This is also the clearest statement of what the script is *not*. Everything above is styled, laid out and accessible with no runtime at all — the missing piece is only the switching, which is exactly the piece HTML does not have.