Separator
One element, one class string, two attributes.
src/components/rahti_ui/separator.rs
Basic
A rule between two things. The whole component is one `<div>`, one class string, and the two attributes that decide which half of the class string applies and whether a screen reader is told about it.
Rahti
A server-rendered Rust web framework.
Everything below the rule is a separate run of content.
`data-horizontal:h-px` and `data-horizontal:w-full` — a one-pixel line in `bg-border`, as wide as whatever contains it. `shrink-0` is what keeps it a line rather than the first thing a cramped flex row gives up.
Vertical
A rule down instead of across, which is one attribute. What it needs from the markup around it is the subject of the next section.
Sessions
412
Users
289
Signups
37
The class string does not change with the orientation — both halves are always in it, and `data-orientation` decides which of them applies. That is shadcn's arrangement, and it is why there is no `separator_variants` that takes an orientation: there would be nothing for it to choose.
The second row is the useful shape: the rules are as tall as the tallest cell, without anyone measuring it. `data-vertical:self-stretch` is `align-self: stretch`, so the flex container does the measuring.
A vertical rule needs a cross axis
The one thing about this component that looks like a bug and is not.
In a flex row — a line.
In a plain block — nothing.
`align-self` is a flex and grid property and does nothing anywhere else, so a vertical separator in a plain block has a width of one pixel and a height of zero. The class list is not wrong; the parent is. Give it `flex`, `grid`, or an explicit height.
shadcn's registry style writes `data-[orientation=vertical]:h-full` instead, which fails the same way for a different reason: `height: 100%` resolves against a parent that has no height either. This port takes the Base UI declaration — see the port section.
Decorative, or announced
The only thing the React component's JavaScript ever decided. Two props in, two attributes out, and no state anywhere — which is why the port is attributes and the `"use client"` does not cross.
Decorative, which is the default.
A screen reader is told nothing about the line above.
Announced.
`role="separator"`, and a screen reader says so.
shadcn's prop is `decorative` and it defaults to `true`: a rule is usually a picture of a division the heading structure already states, and announcing it again is noise. `role="none"` is how ARIA spells that.
The prop could not keep its name. A Rahti tag closes its props literal with `..Default::default()`, so an unwritten `bool` prop is `false` — a `decorative` prop would therefore have defaulted to *not* decorative, silently inverting shadcn's default on every `<Separator />`. Renaming it to `announced` was the smaller divergence than flipping what it does. `SeparatorProps` keeps both words: `.decorative(bool)` and `.announced()`.
`aria-orientation` is written only on a vertical announced separator — the row above is the only place on this page that carries it. That is Radix's own rule and not an omission: `horizontal` is the implicit value of the attribute for `role="separator"`, so writing it says nothing.
element="hr"
shadcn renders a `<div>` with a `role`, because Radix's primitive renders a `Primitive.div`. HTML has had this element all along, and it is a separator to every screen reader, reading mode and print stylesheet without one — so this is one of the few places the port can be more correct than the thing it ports.
A thematic break, in HTML's own element.
No `role` needed — an `<hr>` is already a separator.
Asked for `bg-primary`. Without the reset, an `<hr>` ignores it:
With `border-0` — the colour asked for:
Tailwind's preflight gives `hr` `border-top-width: 1px`, and this project's `globals.css` gives every element `border-border` as its border colour. A border paints over a background, so the line an `<hr>` draws is its border and `bg-border` is never seen — which looks perfectly correct, because the two are the same colour.
It stops looking correct the moment anyone changes one. The pair above is six pixels tall so both are visible at once: a grey hairline along the top of the colour that was actually asked for. At the default `h-px` the border is the whole element and the background never shows at all.
`border-0` hands the line back to `bg-border`, so an `<hr>` behaves like the `<div>` it is standing in for. `SeparatorProps::class_list` adds it whenever the element is an `hr`, so nothing at a call site has to know.
A page writing its own `<hr>` with `separator_variants` does have to know: that helper returns the `<div>` list, because it has no idea what element it is going on. Join `HR_RESET` in by hand.
`element="hr"` is horizontal only. An `<hr>` is a paragraph-level thematic break and a vertical one would be an element saying something the page does not mean; the pair trips a `debug_assert` rather than rendering a lie.
Spacing, and what an override costs
`class` is `className`: laid over the component's own, with a conflicting utility winning. Where that resolution happens is the thing to know.
Overridden — `class="my-6"`.
The margin arrives when PulsePoint mounts.
Served — `separator_variants("my-6")` on a native element.
The margin is in the HTML.
There is no Rust tailwind-merge, so an override turns the class attribute into `{twMerge(…)}` for the browser to resolve — and a binding is literal text until the runtime compiles it. Both rules above look the same once the page has mounted; only the second one is a rule before that.
It matters more here than on most components. A margin is very nearly the only reason to touch a separator's class at all — the line itself is one pixel and there is nothing in it to restyle — so the override path is the common path, and the served alternative is worth knowing about.
Which separator to reach for
There are three in this library and they are not interchangeable. Two of them belong to a family and take that family's defaults; this one is the plain case.
Separator — a rule between two things.
FieldSeparator — a rule with a word in a gap through it.
ButtonGroupSeparator — a rule inside a welded strip.
Three separators, one class string. `separator::BASE` is what all three draw from — a FieldSeparator lays `absolute inset-0 top-1/2` over it so a chip of text can be painted through the middle, and a ButtonGroupSeparator takes it without `bg-border` and supplies `bg-input` instead, because a rule inside a welded strip is continuing the buttons' borders rather than the page's.
The two family separators were written before this component existed, and each kept its own copy of the string with a note saying the port would have one thing to replace. This is that replacement: `field::SEPARATOR_RULE` is now an alias for `separator::BASE`, and nothing else in either file moved.
The port
Which shadcn separator this is, and the one declaration the two styles disagree about.
Unusually, both of shadcn's styles spell this component in utilities — the Base UI style normally hides its design behind `cn-*` marker classes an external stylesheet defines, which is why the Card had to be taken from the registry. Here the two differ in exactly one declaration.
registry …data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px base ui …data-vertical:self-stretch data-vertical:w-px
This port takes the Base UI half. `h-full` is `height: 100%`, which resolves against the parent's height and collapses to nothing when the parent has none; `self-stretch` is `align-self: stretch`, which fills the cross axis of a flex or grid parent — and a flex row is where a vertical separator actually lives, including in shadcn's own demo.
It is also the declaration already in this repository. FieldSeparator and ButtonGroupSeparator were drawing their rules from it long before this component landed, so taking the registry's `h-full` now would have changed what those two render.
The short orientation spelling is the other thing kept from the Base UI style, and it is a house rule rather than a decision made here: `data-horizontal:` and `data-vertical:` are `@custom-variant`s that `src/app/globals.css` defines. Without those definitions every utility in the base misses and the separator draws nothing at all.
What did not cross is the `"use client"` and the `radix-ui` dependency behind it. Radix's `Separator.Root` has no state, no effect and no event — it decides `data-orientation`, and then either `role="none"` or `role="separator"` plus an `aria-orientation` for the vertical case. Written on the server, that is the whole component.
The boundary
A Separator is a component, and a component is a PulsePoint scope.
Rendered into this page's block, with no boundary around it.
A `render` returns the element and nothing else.
Three ways across the boundary, and only one of them is the tag. The tag is right for everything static; `SeparatorProps::render` is for a binding or a separator a page drives, because interpolating it puts the element in the calling block's scope; `separator_variants` is for an element the page has to own outright.
Write `data-orientation` alongside the variants helper. Every utility in the base is behind `data-horizontal:` or `data-vertical:`, so without that attribute the class list styles nothing — which looks exactly like the vertical-with-no-cross-axis failure and is a different problem.
A `<div>` separator that needs a scope holds its `<script>` inside itself and stays one literal element, so it is still a real sibling of whatever it divides. An `<hr>` is void and cannot, so that branch pays for a fragment: invisible to layout, visible to a `+` or `:last-child` selector a page might have written around it.
Controlled, page scope
`hidden` is the one attribute worth binding on a separator, and it is the one the browser runtime rewrites on purpose: a binding whose name ends in an HTML boolean-attribute word becomes a boolean attribute, present when truthy and gone when falsy. That is exactly what `hidden` wants — and exactly why an invented `data-open` or `data-selected` must never be bound, because the same rule catches them and truncates the name.
Above the rule.
Below the rule.
hidden: {collapsed ? "true" : "false"}
Built with `separator()` and rendered into the page's own block. Through the `<Separator>` tag the binding would be compiled in the component's scope, which has no `collapsed` — it would not error, it would simply never update. Every `render` in this library carries no `<script>`, which is what keeps this element inside the page's scope.