Separator
Half a pixel of translucent grey, decorative unless it divides something.
Mihir Modi
Design engineer.
The idea
Half a pixel in the alpha border grey, so it is one device pixel on a retina screen and reads on page, card or glass without becoming a painted stripe. Most lines only confirm a gap that spacing already made, so decorative is the default and a screen reader is not told about them. A label breaks the line around itself instead of sitting on a patch of background, so it works on any surface.
Composes Button.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Separator } from "mihirmodi-ui";
<Separator className="my-4" />
<nav className="flex h-4 items-center gap-3">
<a href="/work">Work</a>
<Separator orientation="vertical" />
<a href="/writing">Writing</a>
</nav>
// Decorative by default. When the line really divides content,
// make it a separator a screen reader announces.
<Separator label="or" decorative={false} />Props
| Prop | Type | Notes |
|---|---|---|
| orientation"horizontal" | "vertical" | "horizontal" | "vertical"= "horizontal" | Vertical stretches to its flex row. |
| decorativeboolean | boolean= true | `false` makes it a real `separator` with an orientation. |
| labelReactNode | ReactNode | A word held in the middle — “or”, a date. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
import type { ReactNode } from "react";
export type SeparatorProps = {
orientation?: "horizontal" | "vertical";
/**
* `true` (the default) when the line only draws a gap that spacing already
* makes, so assistive tech skips it. `false` when it divides content a
* reader should hear divided — then it is a real `separator`.
*/
decorative?: boolean;
/** A word held in the middle of the line — "or", a date. Horizontal only. */
label?: ReactNode;
className?: string;
};
/**
* A hairline between two things.
*
* Half a pixel in the translucent border grey, so it is a full device pixel on
* a retina screen and reads on any surface it is laid over — page, card or
* glass — without turning into a painted stripe. Most lines on a page are
* decoration: spacing has already grouped the content and the rule only
* confirms it, so that is the default, and a screen reader is not told about a
* line it could not have needed.
*
* A label sits in the faint tier with the line broken around it, rather than
* on a patch of background laid over the line, so it works on any surface.
* On a decorative line the label is ordinary text and is read; on a semantic
* one a separator's children are presentational, so a string label becomes
* its accessible name instead.
*/
export function Separator({ orientation = "horizontal", decorative = true, label, className = "" }: SeparatorProps) {
const a11y = decorative
? ({ role: "none" } as const)
: ({ role: "separator", "aria-orientation": orientation } as const);
if (orientation === "vertical") {
return <div {...a11y} className={`w-0 self-stretch border-l-[0.5px] border-gray-alpha-400 ${className}`} />;
}
if (label) {
return (
<div
{...a11y}
aria-label={!decorative && typeof label === "string" ? label : undefined}
className={`flex items-center gap-3 ${className}`}
>
<span aria-hidden className="h-0 flex-1 border-t-[0.5px] border-gray-alpha-400" />
<span className="shrink-0 text-[12px] leading-none text-faint">{label}</span>
<span aria-hidden className="h-0 flex-1 border-t-[0.5px] border-gray-alpha-400" />
</div>
);
}
return <div {...a11y} className={`h-0 w-full border-t-[0.5px] border-gray-alpha-400 ${className}`} />;
}