Separator
A 1px hairline between groups, across or down.
shadcn's base-nova separator, unchanged — the --border hairline, rendered as a role="separator" with its aria-orientation. Keep that role where the line ends one part of the content and starts the next — two sections of a document, the groups of a toolbar — and hide the line with aria-hidden where it is only rhythm: between words on one line, between rows a list already separates, either side of a word like "or". Where the kit draws a section divider it reaches for --rule, one weight heavier, with a className. Inside a dropdown, DropdownMenuSeparator is this same Base UI part with the menu's spacing.
Install
npx shadcn@latest add @vibra/separatorNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Separator } from "@/components/ui/separator"
export default function SeparatorDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Northwind Analytics</p>
<p className="text-sm text-muted-foreground">Team plan · 12 seats · renews October 1</p>
</div>
{/* Between the account and its sections: a separator a screen reader hears. */}
<Separator />
{/* Between words on one line the bars are punctuation, not structure, so they are hidden. */}
<div className="flex h-5 items-center gap-3 text-sm text-muted-foreground">
<span>Overview</span>
<Separator orientation="vertical" aria-hidden="true" />
<span>Members</span>
<Separator orientation="vertical" aria-hidden="true" />
<span>Billing</span>
</div>
</div>
)
}Decorative or semantic
Between who is billed and what they owe, a separator that is heard, in --rule; between the rows inside, hidden hairlines.
import { formatCurrency } from "@/lib/format"
import { Separator } from "@/components/ui/separator"
const LINES = [
{ label: "Starter plan, 9 seats", amount: 108 },
{ label: "Tax", amount: 0 },
]
// Two kinds of line on one invoice. Between who is billed and what they owe
// runs a separator a screen reader hears: it ends one part of the document and
// starts the next, and it is drawn in --rule, one weight heavier. The hairlines
// between the rows are rhythm inside one part, so they are hidden.
export default function SeparatorSemantic() {
return (
<article aria-label="Invoice INV-100004" className="flex w-full max-w-sm flex-col gap-3 text-sm">
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs text-muted-foreground">INV-100004</span>
<span className="font-medium">Meridian Group</span>
<span className="text-muted-foreground">Billed to accounts payable, due September 9</span>
</div>
<Separator className="bg-rule" />
<div className="flex flex-col">
{LINES.map((line) => (
<div key={line.label} className="flex flex-col">
<div className="flex items-baseline justify-between gap-3 py-1.5">
<span>{line.label}</span>
<span className="tabular-nums">{formatCurrency(line.amount)}</span>
</div>
<Separator aria-hidden="true" />
</div>
))}
<div className="flex items-baseline justify-between gap-3 pt-1.5 font-medium">
<span>Total due</span>
<span className="tabular-nums">{formatCurrency(108)}</span>
</div>
</div>
</article>
)
}With a label
"or" between two ways to sign in: the word is read in its place, and the lines either side of it are hidden.
import * as React from "react"
import { KeyRoundIcon, MailIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
// "or" between two ways in. The word is what a screen reader needs, and it is
// read in its place; the lines either side of it are decoration, so they are
// hidden rather than announced as two separators around one word.
export default function SeparatorLabel() {
const id = React.useId()
return (
<div className="flex w-full max-w-xs flex-col gap-3">
<Button variant="outline">
<KeyRoundIcon data-icon="inline-start" aria-hidden="true" />
Continue with single sign-on
</Button>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<Separator aria-hidden="true" className="flex-1 data-horizontal:w-auto" />
<span>or</span>
<Separator aria-hidden="true" className="flex-1 data-horizontal:w-auto" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={id}>Work email</Label>
<Input id={id} type="email" autoComplete="email" />
</div>
<Button>
<MailIcon data-icon="inline-start" aria-hidden="true" />
Email me a sign-in link
</Button>
</div>
)
}In a toolbar
Vertical separators between the groups of a chart toolbar: structure a screen reader hears, which the arrow keys pass over.
"use client"
import * as React from "react"
import { ChartColumnIcon, ChartLineIcon, DownloadIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Toolbar, ToolbarGroup } from "@/components/ui/toolbar"
const RANGES = ["7 days", "30 days", "90 days"]
const PRESSED = "aria-pressed:bg-brand-muted aria-pressed:text-foreground"
// Inside a toolbar the lines are structure: each separates one group of
// controls from the next, so each is a vertical separator a screen reader
// hears, and the arrow keys pass over it from the last control of one group to
// the first of the next.
export default function SeparatorToolbar() {
const [range, setRange] = React.useState("30 days")
const [chart, setChart] = React.useState<"line" | "bar">("line")
return (
<Toolbar aria-label="Revenue chart" size="sm">
<ToolbarGroup role="group" aria-label="Range">
{RANGES.map((value) => (
<Button key={value} variant="ghost" size="xs" aria-pressed={range === value} className={PRESSED} onClick={() => setRange(value)}>
{value}
</Button>
))}
</ToolbarGroup>
<Separator orientation="vertical" className="mx-0.5 my-1" />
<ToolbarGroup role="group" aria-label="Chart type">
<Button variant="ghost" size="icon-xs" aria-label="Line chart" aria-pressed={chart === "line"} className={PRESSED} onClick={() => setChart("line")}>
<ChartLineIcon aria-hidden="true" />
</Button>
<Button variant="ghost" size="icon-xs" aria-label="Bar chart" aria-pressed={chart === "bar"} className={PRESSED} onClick={() => setChart("bar")}>
<ChartColumnIcon aria-hidden="true" />
</Button>
</ToolbarGroup>
<Separator orientation="vertical" className="mx-0.5 my-1" />
<Button variant="ghost" size="icon-xs" aria-label="Download as CSV">
<DownloadIcon aria-hidden="true" />
</Button>
</Toolbar>
)
}In a menu
A settings menu in two labelled groups, so the line between them is decoration; the current page sits on the selected plane.
import * as React from "react"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const GROUPS = [
{ label: "Account", links: [{ title: "Profile", href: "/settings/profile" }, { title: "Security", href: "/settings/security" }] },
{ label: "Workspace", links: [{ title: "Members", href: "/settings/members" }, { title: "Billing", href: "/settings/billing" }] },
]
const CURRENT = "/settings/billing"
// A settings menu in two groups. Each group is a list named by its label, so
// the grouping is already in the structure and the line between the groups is
// decoration, hidden from screen readers. Inside a dropdown, the menu's own
// DropdownMenuSeparator is this same part with the menu's spacing, and there a
// separator is how the groups are told apart.
export default function SeparatorMenu() {
const id = React.useId()
return (
<nav aria-label="Settings" className="flex w-full max-w-56 flex-col panel p-1.5 text-sm">
{GROUPS.map((group, index) => (
<React.Fragment key={group.label}>
{index > 0 ? <Separator aria-hidden="true" className="-mx-1.5 my-1.5 data-horizontal:w-auto" /> : null}
<p id={`${id}-${index}`} className="px-2 pt-1 pb-1 text-xs font-medium text-muted-foreground">
{group.label}
</p>
<ul aria-labelledby={`${id}-${index}`} className="flex flex-col">
{group.links.map((link) => (
<li key={link.href}>
<a
href={link.href}
aria-current={link.href === CURRENT ? "page" : undefined}
className={cn(
"flex h-7 items-center rounded-md px-2 focus-ring hover:bg-muted",
link.href === CURRENT && "bg-brand-muted font-medium hover:bg-brand-muted"
)}
>
{link.title}
</a>
</li>
))}
</ul>
</React.Fragment>
))}
</nav>
)
}In a row of figures
Hidden vertical lines between four figures, which step out of the way when the row becomes two by two on a phone.
import { Separator } from "@/components/ui/separator"
const STATS = [
{ label: "MRR", value: "$211,056" },
{ label: "Active accounts", value: "176" },
{ label: "Seats", value: "5,560" },
{ label: "On trial", value: "21" },
]
// A row of figures, each a list item with its label and its value. The lines
// between them are rhythm, not structure — the list already tells the figures
// apart — so they are hidden. On a phone the row becomes two by two and the
// vertical lines step out of the way.
export default function SeparatorStats() {
return (
<ul aria-label="Northwind this month" className="grid w-full max-w-2xl grid-cols-2 gap-y-4 sm:flex">
{STATS.map((stat, index) => (
<li key={stat.label} className="relative flex flex-1 flex-col gap-1 px-4">
{index > 0 ? (
<Separator orientation="vertical" aria-hidden="true" className="absolute inset-y-0 start-0 max-sm:hidden" />
) : null}
<span className="text-xs text-muted-foreground">{stat.label}</span>
<span className="text-xl font-semibold tabular-nums">{stat.value}</span>
</li>
))}
</ul>
)
}Inset, in a list
Lines that start where the names do, past the faces, set on the inline axis so they follow a right-to-left page.
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Separator } from "@/components/ui/separator"
const ADMINS = [
{ name: "Sonia Keller", initials: "SK", role: "Owner" },
{ name: "Kwame Halvorsen", initials: "KH", role: "Admin" },
{ name: "Aisha Nakamura", initials: "AN", role: "Admin" },
]
// Rows divided the way a list of people reads: each line starts where the
// names start, past the faces, so the faces run down one clean column. The
// inset is margin-inline-start, so it follows the text to the right in a
// right-to-left page. The list already separates the rows, so the lines are
// hidden from screen readers.
export default function SeparatorInset() {
return (
<ul aria-label="Workspace admins" className="flex w-full max-w-sm flex-col">
{ADMINS.map((admin, index) => (
<li key={admin.name} className="flex flex-col">
{index > 0 ? <Separator aria-hidden="true" className="ms-11 data-horizontal:w-auto" /> : null}
<div className="flex items-center gap-3 py-2.5">
<Avatar aria-hidden="true">
<AvatarImage src={avatarFor(admin.name)} alt="" />
<AvatarFallback>{admin.initials}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{admin.name}</span>
<span className="text-xs text-muted-foreground">{admin.role}</span>
</div>
</li>
))}
</ul>
)
}Turns with the layout
Vertical between two panes side by side, horizontal once they stack, with an aria-orientation that follows the drawing.
"use client"
import { useMediaQuery } from "@/hooks/use-media-query"
import { Separator } from "@/components/ui/separator"
// Two parts of the billing page side by side from 640px, stacked below it.
// The line is drawn by the same breakpoint as the row, in CSS, so the server's
// page is right before any script runs — a media query read in React is false
// on the server, and a horizontal line squeezed the two parts to slivers until
// hydration. The hook only tells a screen reader which way the line runs.
export default function SeparatorResponsive() {
const wide = useMediaQuery("(min-width: 40rem)")
return (
<section aria-label="Billing" className="flex w-full max-w-lg flex-col gap-4 text-sm sm:flex-row sm:gap-6">
<div className="flex flex-1 flex-col gap-1">
<span className="text-xs text-muted-foreground">Current plan</span>
<span className="font-medium">Team, 30 seats</span>
<span className="text-muted-foreground">$49 a month, billed monthly</span>
</div>
<Separator
orientation={wide ? "vertical" : "horizontal"}
className="sm:data-horizontal:h-auto sm:data-horizontal:w-px sm:data-horizontal:self-stretch"
/>
<div className="flex flex-1 flex-col gap-1">
<span className="text-xs text-muted-foreground">Next invoice</span>
<span className="font-medium tabular-nums">$49.00 on October 1</span>
<span className="text-muted-foreground">Paid automatically from the card on file</span>
</div>
</section>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| orientation | "horizontal" | "vertical" | "horizontal" | Which way the line runs, and its aria-orientation. A vertical line stretches to the height of its row. |
| aria-hidden | boolean | — | Set it when the line is only rhythm and the content around it is already told apart, so no separator is announced. |
Dependencies
Registry
Source
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }