Divider
A hairline rule that separates content. Horizontal or vertical, solid / dashed / dotted, three thickness steps, tonal strength that adapts to coloured surfaces, an inline label, and an inset that aligns the line to the row's text edge.
pnpm add @tessinaui/uiWhen to use
- Whitespace alone cannot group the content. A list of rows that would otherwise run together, or a total that must read as separate from the lines above it.
- Two groups of controls share a bar. A toolbar with two groups of icons gets a vertical rule between them.
- List rows with a leading icon or avatar.
insetstarts the rule at the row's text edge, so the icon column reads as one stack. - An "or" between two choices.
labelputs a short word between two flanking rules, the way an auth screen does. - Groups, not peers. Divide the groups; leave items inside a group to the gap.
When not to use
- When space or a card already groups the content. Reach for gap and padding first. The app survey agreed: nine of the surveyed products group content with a card and a gap and ship no rule at all.
- Between every item. A divider between every item makes a list look busier, not clearer.
- Inside a card or modal that already separates its content. The container is the boundary.
- As a section caption. A centred caption with no rule either side (a chat day-stamp, a settings section caption) is not a divider. Use Label or Badge; for a heading above a rule, compose a heading and a plain divider (see Labels).
- For a thick band. Heavier than
xlis a filled background; paint it with a Surface. - As decoration, or to cover a layout problem. A rule should clarify grouping; fix the spacing instead of hiding it behind a line. Gaps are Spacer and Stack work.
Installation
pnpm add @tessinaui/uiUsage
import { Divider } from "@tessinaui/ui";<Divider />Examples
Default
A horizontal rule separating two blocks of content.
Inset
The list separator every phone ships: the rule starts where the row's text
starts, not at the frame, so a leading icon column reads as one stack. Set
--divider-inset to the row's own inline padding. On a vertical divider the
same prop shortens the rule along its own axis — the toolbar rule that stops
short of the bar's edges.
Variants
Three line styles — solid, dashed, and dotted. Dashed and dotted read as
receipts, tickets and pricing cards.
Sizes
Three thickness steps: md (1px hairline, the default), lg (2px) and xl
(4px). Anything heavier than 4px is a filled band rather than a line — paint it
with a Surface.
Tones
subtle, default and strong are steps of the same strength ladder. On a
coloured plate the ladder is re-expressed in the surface's own ink, and
Divider picks that up from the enclosing Surface on its own — you do not
pass tone="on-color" by hand.
On a coloured plate
Every way a divider meets colour: a paired Surface background, hand-painted
dark and pale plates (on a pale plate the ink is black, so the rule darkens),
a vertical rule, and a plate painted without a Surface, where you pass
tone="on-color" yourself. A label on a plate takes the plate's own muted ink
rather than a grey, which would sink below AA on a saturated fill.
Emphasis
Weight carries meaning: a hairline separates peers, a heavier and darker rule marks the line a total sits below.
With label
An inline label between two flanking rules — the "or" of an auth screen.
labelPosition moves it to the start or end.
Vertical
Vertical rules separating inline items. A vertical divider stretches to its flex parent, so that parent needs a height.
Semantic separator
Most dividers are decoration and stay silent. decorative={false} emits a real
role="separator"; give it a label and the name is wired up by reference so
it is actually announced.
Custom element
render swaps the host element — most usefully <li>, because a <div>
between list items breaks the list for assistive tech.
API Reference
Divider
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" | Layout axis |
variant | "solid" | "dashed" | "dotted" | "solid" | Line style |
size | "md" | "lg" | "xl" | "md" | Thickness: 1 / 2 / 4 px |
tone | "default" | "subtle" | "strong" | "on-color" | "default" | Strength of the rule; adapts to a coloured surface |
spacing | "none" | "xs" | "sm" | "md" | "lg" | "xl" | "none" | Gap opened across the rule |
inset | "none" | "start" | "end" | "both" | "none" | Pull the rule's ends in along its own axis |
label | ReactNode | — | Inline label between two flanking rules |
labelPosition | "start" | "center" | "end" | "center" | Where the label sits along the axis |
decorative | boolean | true | If false, emits role="separator" + aria-orientation |
render | RenderProp | — | Swap the host element (e.g. <li />) |
dir | "ltr" | "rtl" | — | Text direction |
role | AriaRole | — | Explicit role override |
className | string | — | Extra classes on the root element |
The component extends all standard div HTML attributes.
Exports
| Export | Type | Description |
|---|---|---|
Divider | component | The rule itself |
dividerVariants | cva | The line variants, for composing a rule elsewhere |
Data attributes
Stable hooks for styling, testing and automation.
| Attribute | On | Value |
|---|---|---|
data-slot="divider" | root | — |
data-slot="divider-line" | each rule | — |
data-slot="divider-label" | label | — |
data-orientation | root | horizontal | vertical |
data-variant | root | solid | dashed | dotted |
data-tone | root | default | subtle | strong | on-color |
data-on-color | root | "true" when on a coloured surface; absent otherwise |
data-inset | root | none | start | end | both |
CSS custom properties
| Property | Default | Effect |
|---|---|---|
--divider-inset | 1rem | Distance the inset prop pulls each end in |
Variants
| Variant | Description |
|---|---|
solid | Continuous line. Default. |
dashed | Evenly spaced dashes. |
dotted | Fine dotted pattern. |
Sizes
| Size | Thickness | When to use |
|---|---|---|
md | 1px | The hairline. Default, and correct for almost every list. |
lg | 2px | Emphasis — the rule above a checkout total, a table header. |
xl | 4px | A hard section break. |
Heavier than 4px is a band, not a line: in every surveyed app, thick section
splits were a filled background showing between grouped blocks. Use a
Surface.
Orientations
| Orientation | Notes |
|---|---|
horizontal | Spans the full width of its container. Default. |
vertical | Stretches to a flex parent (align-self: stretch); that parent must have a height. |
Tones
| Tone | On a plain surface | On a coloured surface | When to use |
|---|---|---|---|
subtle | border/50 | current/10 | Barely-there separation inside grouped content. |
default | border | current/20 | Standard separation. Default. |
strong | muted-foreground | current/40 | Emphasis — section breaks, checkout totals. |
on-color | — | current/20 | Only needed when you paint a coloured plate yourself, without a Surface. |
The on-color column is a tint of the surface's own ink, never a literal
white: an on-color surface is not always dark (in dark mode --primary and
--foreground are near-white), and a white rule on one of those is invisible.
Inset
inset pulls the ends of the rule in along its own axis, so the line can start
at the row's text edge instead of the frame. This is the default separator
shape on iOS and the common inset / middle divider, and it is what
CONVENTIONS §7b.1 asks for inside popups.
| Value | Effect |
|---|---|
none | Full-bleed, edge to edge. Default. |
start | Inset at the leading end only — the iOS list separator. |
end | Inset at the trailing end only. |
both | Inset at both ends — the "middle" divider. |
The distance is the --divider-inset custom property, default 1rem. Set it
to whatever your row actually uses:
<Divider inset="start" className="[--divider-inset:3.25rem]" />Insets use logical properties, so they follow dir without a second map.
They are padding on the divider's own box, so the rule stops exactly at the
inset and never pushes past its container — tune --divider-inset rather than
adding px-*, which would replace it.
Labels
Pass label (or children) to render text between two flanking rules.
labelPosition controls where the label sits.
<Divider label="or" />
<Divider label="Recently" labelPosition="start" />
<Divider label="Archive" labelPosition="end" />For a section caption above a rule — the form-section pattern — compose a heading and a plain divider instead. That keeps the heading a real heading in the accessibility tree, which a label inside a divider can never be.
<h3 className="text-sm font-medium">Billing</h3>
<Divider spacing="sm" />Accessibility
By default a Divider is decorative: it emits no role at all, both
rules are aria-hidden, and screen readers skip it. A labelled decorative
divider is the exception: the rules are still skipped, but the label is read as
ordinary text — the "or" on an auth screen means something. Set
decorative={false} when the boundary itself carries meaning, and it emits
role="separator" with the matching aria-orientation.
Emitting no role rather than role="none" is deliberate. On the default <div>
the two are equivalent — a <div> is already generic — but role="none"
overwrites whatever host render supplies, and a render={<li />} divider
carrying role="none" strips the list item's role and breaks the whole list.
Pass role="none" explicitly if you want it.
<Divider /> {/* silent */}
<Divider decorative={false} /> {/* semantic separator */}
<Divider decorative={false} orientation="vertical" /> {/* vertical separator */}A labelled semantic separator is a trap, and this component steps around it.
WAI-ARIA marks separator as children presentational: any text nested inside
one is erased for assistive tech, so a labelled divider that puts its text in
the separator element announces nothing at all. separator does still take a
name from the author, so Divider gives the label an id and points
aria-labelledby at it — the rule is
announced with its name. A consumer's own aria-label or aria-labelledby
always wins.
<Divider decorative={false} label="Archive" />
{/* announced as "Archive, separator" */}Inside a list, use render={<li />}. A <div> between <li>s is invalid list
structure and assistive tech will report the list length wrongly.
Contrast here is a non-text concern (WCAG 1.4.11). subtle is deliberately a
low-contrast tone: it is for decorative separation inside content that is
already grouped, and must never be the sole boundary of a control. Measured
ratios for every tone in both themes are in the component's variant ledger.
Assistive technology matrix
| AT | Status |
|---|---|
| VoiceOver (macOS) | Passed (2026-09-10, Safari) — a decorative divider is silent; decorative={false} announces "horizontal splitter"; with a label, "horizontal splitter Kestrel". Transcript in the variant ledger |
| NVDA | Untested — not runnable on the maintainers' machines |
| JAWS | Untested — not runnable on the maintainers' machines |
RTL
The divider uses logical properties throughout — border-inline-start for the
vertical rule, margin-inline for spacing, padding-inline for insets — so vertical rules and
start/end insets flip correctly in right-to-left layouts. labelPosition="start"
puts the label on the right under dir="rtl". Pass dir when you need explicit
direction context.