Scroll Area
The browser's scrollbar, replaced by one that looks the same everywhere.
src/components/rahti_ui/scroll_area.rs
Basic
A vertical scroll area, which is what an unadjusted one is.
shadcn's own demo, part for part. The area is `h-72 w-48` because the root is `relative` and nothing else — it has no size of its own, so without a height there is nothing for the content to overflow and no scroll area at all.
Hover it. The bar is `hover` by default, which is Radix's default: on while the pointer is over the area or just after a scroll, then faded out after 600ms. The thumb's height is `clientHeight / scrollHeight` of the track, measured in the browser — there is no server-side answer to how tall it should be, which is why this is the first component in the library that ships a script.
Horizontal
The same component, turned ninety degrees. `flex-col` on the bar is what makes the thumb grow the other way — one class, both axes.
`axis="horizontal"` is what shadcn spells by writing a `<ScrollBar orientation="horizontal" />` *inside* the area. That call shape cannot be ported: children arrive here as one opaque `Html` and everything in them lands inside the viewport, which is the one place a scrollbar must not be. React gets away with it because the scrollbar reads its area through context and positions itself out of flow; there is no context to reach back out with here.
The content wrapper is `display: table`, which is Radix's trick and the reason this works without the call site adding `w-max`: a table box is shrink-to-fit, so it is as wide as the row of covers wants to be, and `min-w-full` stops it collapsing narrower than the viewport when the content is small.
Both axes
A table that is too wide and too tall. The corner is the only part of shadcn's DOM this port does not render.
Two bars, and each stops short of the other. Radix arranges that by rendering a corner element that publishes `--radix-scroll-area-corner-width` for the bars to read; here Rust knows at render time whether there are two, so the inset is written directly — and the vertical bar gives up shadcn's `h-full` for a `bottom-2.5` in that one case, because a height and a bottom inset are two answers to the same question.
Which is also why there is no `[data-slot=scroll-area-corner]` in the DOM. Radix needs the element to exist for the custom property; here it would be an empty transparent box, so it is left out.
When the bar is on screen
Four modes, and the default is `hover` because Radix's is.
hover — pointer over the area, or just after a scroll
scroll — only just after a scroll
auto — whenever the content overflows
always — overflowing or not
Radix calls this `type`. `type` is a Rust keyword and `r#type` would be the spelling — `Input` takes that route, because `type` is a real HTML attribute there. Here it is not an attribute at all, just a name Radix chose, so the prop is called what it does.
The fourth box does not overflow, and only `always` draws a bar for it. That is the whole difference between `always` and `auto`, and it is worth seeing once: `auto` is "show it if there is something to scroll", `always` is "show it regardless".
Keyboard and screen readers
The one place this port is deliberately more than shadcn's.
`tabindex="0"` on the viewport is this port's, not shadcn's. A region that scrolls has to be reachable by keyboard — WCAG 2.1.1 — and Radix does not do it, which is also why shadcn's viewport carries a `focus-visible:ring-[3px]` that nothing could ever trigger. Tab into the middle box and the ring is what you see; arrow keys and Page Up/Down then scroll it.
The third box is the other half of the rule: the script takes the attribute away again when the content does not overflow, so a region that cannot scroll is not a tab stop. It is served with `tabindex="0"` and loses it on mount — the served value is the one that matters if the script never runs.
`label` is what makes the tab stop mean something: it writes `aria-label` and the `role="region"` that carries it, and a focusable box without one is announced as nothing at all. Without a label the role is left off rather than claimed, because an unnamed region is not a landmark.
The port
Which shadcn scroll area this is, and the first one whose script had to come with it.
The registry style, as usual — shadcn's Base UI spelling writes this one into `cn-scroll-area`, `cn-scroll-area-viewport` and `cn-scroll-area-thumb` marker classes an external stylesheet defines. Aspect Ratio was the exception in this library, and only because its Base UI spelling had no markers in it.
What is different here is that the JavaScript actually crosses. Card, Separator and Aspect Ratio each turned out to be attributes and a class list once the markup was written on the server — the `"use client"` was paying for nothing a server could not decide. A scroll area replaces the browser's scrollbar with two divs, and the size and position of the thumb are measurements of the live DOM. There is no server-side answer, so the behaviour is ported rather than dissolved.
Radix here
-------------------------------------- ------------------------------------
Root position: relative `relative`, plus the script
Viewport overflowX/Y: scroll | hidden `overflow-*` chosen from the axis
injected <style> hiding `[scrollbar-width:none]` and
native scrollbars `[&::-webkit-scrollbar]:hidden`
Content min-width:100%; display:table `table min-w-full`
Scrollbar insets from --corner-width `absolute` + insets chosen in Rust
Thumb --radix-...-thumb-height an inline height/width, same measure
Corner an element sized from both nothingOne substitution in the class strings, and it is named where it happens: the bar writes `transition` where shadcn writes `transition-colors`. Radix animated the scrollbar's appearance itself, so shadcn's string never needed opacity in the transition; this port fades the bar with a class, and Tailwind's bare `transition` is `transition-colors` plus opacity. One utility rather than two, so nothing conflicts and the join stays exact.
And one thing this component cannot degrade gracefully. The viewport hides the browser's own scrollbars from the first byte, so before the script mounts — and forever, if it never does — the region scrolls by wheel, touch and keyboard but draws nothing to say so. That is Radix's trade too, and the alternative is worse: serving native scrollbars and hiding them at mount trades a rare degradation for a visible reflow on every single load.
The boundary, and the missing render
A ScrollArea is a component, and for once the component is the only way in.
Every other component in this library has a `props.render(…)` that returns markup with no `<script>` in it, so a page can interpolate it into its own block and bind to it. This one has none, and the reason is the same reason the component exists: the behaviour is a `<script>`, and a `<script>` only mounts the block it is *lexically written in*. That block also has to be the one carrying the `pp-component` marker, or every scroll area on the page shares one identity.
What is offered instead is the class lists — `scroll_area_variants`, `scroll_area_viewport_variants`, `scroll_bar_variants` and `scroll_area_thumb_variants` — for a page that means to build *and drive* its own. The box above takes them and writes no script, which is exactly what that gets you: a region that scrolls, and a full-length thumb that never moves. The thumb is full-length because `flex-1` fills the track until something measures it.
A binding on the tag is worth even less here than usual. Through a component tag a `{…}` on the root is read as a prop expression and resolved where the tag was written — the page's scope, not the component's — so it reaches the page's state and lands on the root as an ordinary attribute. Fine for `onclick`; useless for anything that wanted to talk to the scrollbars.