FoundationsSeparator

A one-pixel rule, horizontal or vertical, decorative by default.

Preview

Craft

each entry is a live demo plus the decisions behind it.

wave a
10 components
foundations

The thinking

This is the smallest component in the system, and the easy version is a <div> with a background color. I build it on Radix instead because the one decision worth making here is invisible: whether a given line is rhythm or meaning. Most rules are spacing you can see — they organize the eye, and a screen reader narrating each one would be noise. So the line is one pixel of --border that carries no role until you tell it the break is real. The detail is that the same component answers to both readings, switched by a single prop rather than two different elements.

Interface design

One weight, one color: the rule is a single pixel of bg-borderh-px w-full laid flat, h-full w-px stood up. There's no thickness prop and no tone variants, because a separator that comes in sizes invites weight to do hierarchy's job, and I'd rather hierarchy come from type and spacing. The quiet load-bearing class is shrink-0: separators live inside flex rows, and flex will happily crush a one-pixel child to nothing to make room. Margins stay the consumer's problem, passed through className — the demo's horizontal rule brings its own my-4. The line has no opinions about its surroundings.

Interaction

Nothing here moves, and that's the decision. A separator is never a target — you don't hover it, press it, or focus it — so a transition on it would be feedback for an intent nobody has. The intent-reading all happens upstream, when you decide whether a given line is rhythm or meaning; after that, the component's job is to hold still. The right duration for a line that never changes is none.

Sound design

Silent, and not by exception. The global SoundLayer plays the tap through one capturing click listener, and it only answers elements that carry an interactive role — links, buttons, switches. A decorative separator renders role="none" and a semantic one role="separator", neither of which is on that list, so this component ships no data-silent opt-out. The layer never asks it to speak, so there's nothing to decline.

Empathy

The whole accessibility story is the decorative prop. By default Radix renders the rule role="none" — it organizes the eye and says nothing to a screen reader, because a reader shouldn't stop for rhythm. Pass decorative={false} and it becomes role="separator", with aria-orientation="vertical" added on vertical rules — horizontal is what the role already implies. There's no disabled state, focus ring, or reduced-motion branch because there's no state, focus, or motion to reduce. The one honest gotcha is layout: a vertical rule is h-full, which measures its parent, so inside an unsized container the line simply disappears — the demo gives its row an explicit h-4 for exactly that reason.
separator.tsx
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";

import { cn } from "@/lib/utils";

// decorative by default (radix renders it aria-hidden) — most rules are
// visual rhythm, not meaning. pass decorative={false} when the break is
// semantic and it becomes a real separator role with orientation.
const Separator = ({
	ref,
	className,
	orientation = "horizontal",
	decorative = true,
	...props
}: React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> & {
	ref?: React.Ref<React.ComponentRef<typeof SeparatorPrimitive.Root>>;
}) => (
	<SeparatorPrimitive.Root
		ref={ref}
		decorative={decorative}
		orientation={orientation}
		className={cn(
			"shrink-0 bg-border",
			orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
			className
		)}
		{...props}
	/>
);
Separator.displayName = SeparatorPrimitive.Root.displayName;

export { Separator };