Divider component
Lines between content inside a section, with a label, an icon or ornament, an action like load more, upright in bars, indented in lists, dashed or dotted.
Lines that structure without shouting
A divider separates content inside a section, between two ways to sign in, between days in a chat or between figures in a row. Every line here sits on whole pixels, so it stays crisp at 1 px, while section transitions handle the space between whole sections.
With a label puts the label in the middle, at the start or at the end, between two ways to sign in, as a date in a chat or as the head of a quiet settings area. Icon and ornament marks a scene break in a long text, a mark above a title or a wave or diamond to close a list. With an action lets the line carry a button, earlier replies above a thread or more activity at the end of a feed. Upright stands in toolbars, between meta data and between figures, and lies flat below 1024 px. Inset and dashed starts the line where the text starts in a list and draws dashed and dotted lines on a receipt.
Use spacing first and a line only where spacing is not enough. A labeled divider explains a choice, or sign in with email, better than an empty line. Inset lines in lists keep avatars free and make rows easier to scan.
Variants
Details
What Divider brings along, read from the registry and the source.
- Category
- Data display
- Area
- Elements
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/divider.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The DividerProps interface from components/ui/divider.tsx, with the comments from the code.
orientationoptionalDividerOrientationhorizontalverticalresponsive
responsive: lies flat on phones and tablets, stands upright from 1024 px on (two columns side by side)
labeloptionalReact.ReactNodeText on the line, e.g. "or" or a date. Read out as text, the line itself stays silent.
alignoptional'start' | 'center' | 'end'Where the label, icon or action sits on the line
iconoptionalIconNameornamentoptionalDividerOrnamentdotsdiamondasterismwave
actionoptionalDividerActionA button or link in the line, e.g. "Load more"
lineoptionalDividerLinesoliddasheddotted
toneoptionalDividerTonedefaultstrongaccentfade
insetoptional'none' | 'start' | 'both' | numberIndent so the line starts where the text starts. start: the width of a list avatar or icon (3.5rem, change it with --divider-inset). both: inset on both sides. A number is the start indent in px.
spacingoptional'none' | 'sm' | 'md' | 'lg'Space around the divider along its axis
Accessibility
Read from the source of the component.
- Sets aria-orientation, so screen readers announce purpose and state.
- Uses the roles label and separator.
- Shows a visible focus ring when used with the keyboard.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 237 lines
'use client';
import { cn } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
/*
* Divider: the line between two things inside a section, a card, a list or a
* toolbar. A plain hairline, a line with a label ("or", a date), with an icon
* or an ornament, with an action in the middle ("Show 12 earlier replies"),
* standing upright in a bar, or indented so it starts where the text starts.
* Page transitions between whole sections are a different thing (section
* transitions), this one stays inside the content.
*
* Lines are 1 px borders, never scaled backgrounds, so they land on whole
* pixels and meet the edges of the surface they sit on.
*/
export type DividerOrientation = 'horizontal' | 'vertical' | 'responsive';
export type DividerLine = 'solid' | 'dashed' | 'dotted';
export type DividerTone = 'default' | 'strong' | 'accent' | 'fade';
export type DividerOrnament = 'dots' | 'diamond' | 'asterism' | 'wave';
export interface DividerAction {
label: string;
/** Turns the action into a link */
href?: string;
onClick?: () => void;
icon?: IconName;
/** Quiet count after the label, e.g. hidden replies */
count?: number;
disabled?: boolean;
}
export interface DividerProps {
/** responsive: lies flat on phones and tablets, stands upright from 1024 px on (two columns side by side) */
orientation?: DividerOrientation;
/** Text on the line, e.g. "or" or a date. Read out as text, the line itself stays silent. */
label?: React.ReactNode;
/** Where the label, icon or action sits on the line */
align?: 'start' | 'center' | 'end';
icon?: IconName;
ornament?: DividerOrnament;
/** A button or link in the line, e.g. "Load more" */
action?: DividerAction;
line?: DividerLine;
tone?: DividerTone;
/**
* Indent so the line starts where the text starts. start: the width of a list
* avatar or icon (3.5rem, change it with --divider-inset). both: inset on both sides.
* A number is the start indent in px.
*/
inset?: 'none' | 'start' | 'both' | number;
/** Space around the divider along its axis */
spacing?: 'none' | 'sm' | 'md' | 'lg';
className?: string;
}
const BORDER_STYLE: Record<DividerLine, string> = { solid: 'border-solid', dashed: 'border-dashed', dotted: 'border-dotted' };
const BORDER_TONE: Record<Exclude<DividerTone, 'fade'>, string> = {
default: 'border-border',
strong: 'border-border-strong',
accent: 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))]',
};
/** A fading line needs more ink than a border to read, it is thin only at its ends */
const FADE = 'color-mix(in oklab, var(--foreground) 30%, transparent)';
const SPACE_Y = { none: '', sm: 'my-3', md: 'my-6', lg: 'my-10' } as const;
const SPACE_X = { none: '', sm: 'mx-2', md: 'mx-4', lg: 'mx-6' } as const;
/** One stretch of line. `side` says which end fades out with the fade tone. */
/*
* One stretch of line beside a label, icon or action. The line sits in a box as tall as the row
* and is placed at the middle rounded down to a whole pixel, so it never lands on a half pixel
* and blurs, whatever height the label has.
*/
function Line({ vertical, line, tone, fadeTo, className }: { vertical: boolean | 'responsive'; line: DividerLine; tone: DividerTone; fadeTo?: 'start' | 'end' | 'both'; className?: string }) {
const H = 'inset-x-0 top-[round(down,50%,1px)]';
const V = 'inset-y-0 left-[round(down,50%,1px)]';
const R = 'lg:inset-x-auto lg:inset-y-0 lg:top-0 lg:left-[round(down,50%,1px)]';
let inner: React.ReactNode;
if (tone === 'fade') {
// A fading line cannot be a border, it is a 1 px gradient that ends in the surface
const dir = fadeTo === 'start' ? 'to left' : fadeTo === 'end' ? 'to right' : null;
const grad = dir ? `linear-gradient(${dir}, var(--divider-fade), transparent)` : 'linear-gradient(to right, transparent, var(--divider-fade) 20%, var(--divider-fade) 80%, transparent)';
const gradV = dir ? `linear-gradient(${fadeTo === 'start' ? 'to top' : 'to bottom'}, var(--divider-fade), transparent)` : 'linear-gradient(to bottom, transparent, var(--divider-fade) 20%, var(--divider-fade) 80%, transparent)';
inner = (
<span
className={cn(
'absolute block',
vertical === true ? cn(V, 'w-px bg-[image:var(--gv)]') : vertical === 'responsive' ? cn(H, R, 'h-px bg-[image:var(--g)] lg:h-auto lg:w-px lg:bg-[image:var(--gv)]') : cn(H, 'h-px bg-[image:var(--g)]'),
)}
style={{ '--g': grad, '--gv': gradV, '--divider-fade': FADE } as React.CSSProperties}
/>
);
} else {
inner = (
<span
className={cn(
'absolute block',
vertical === true ? cn(V, 'w-0 border-l') : vertical === 'responsive' ? cn(H, R, 'h-0 border-t lg:h-auto lg:w-0 lg:border-l lg:border-t-0') : cn(H, 'h-0 border-t'),
BORDER_STYLE[line],
BORDER_TONE[tone],
)}
/>
);
}
return (
<span aria-hidden className={cn('relative block flex-1 self-stretch', vertical === true ? 'min-h-3' : vertical === 'responsive' ? 'min-w-3 lg:min-h-3 lg:min-w-0' : 'min-w-3', className)}>
{inner}
</span>
);
}
const ORNAMENT: Record<DividerOrnament, React.ReactNode> = {
dots: (
<svg width="28" height="6" viewBox="0 0 28 6" aria-hidden className="block">
<circle cx="3" cy="3" r="2" fill="currentColor" />
<circle cx="14" cy="3" r="2" fill="currentColor" />
<circle cx="25" cy="3" r="2" fill="currentColor" />
</svg>
),
diamond: (
<svg width="22" height="10" viewBox="0 0 22 10" aria-hidden className="block">
<path d="M11 1 15 5 11 9 7 5Z" fill="currentColor" />
<path d="M2 5h3M17 5h3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
),
asterism: (
<svg width="26" height="20" viewBox="0 0 26 20" aria-hidden className="block">
{[
[13, 5],
[6, 15],
[20, 15],
].map(([x, y]) => (
<path key={`${x}${y}`} d={`M${x} ${y - 4}v8M${x - 3.5} ${y - 2}l7 4M${x - 3.5} ${y + 2}l7-4`} stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
))}
</svg>
),
wave: (
<svg width="44" height="10" viewBox="0 0 44 10" aria-hidden className="block">
<path d="M1 5c3.5 0 3.5-4 7-4s3.5 8 7 8 3.5-8 7-8 3.5 8 7 8 3.5-8 7-8 3.5 4 7 4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
),
};
/**
* A line between two pieces of content, plain or with a label, icon, ornament or action.
* <Divider label="or continue with" />
* <Divider action={{ label: 'Show 12 earlier replies', onClick }} />
* <Divider orientation="vertical" />
*/
export function Divider({ orientation = 'horizontal', label, align = 'center', icon, ornament, action, line = 'solid', tone = 'default', inset = 'none', spacing = 'none', className }: DividerProps) {
const vertical = orientation === 'vertical' ? true : orientation === 'responsive' ? 'responsive' : false;
const insetStyle = typeof inset === 'number' ? ({ marginInlineStart: inset } as React.CSSProperties) : undefined;
const insetClass = inset === 'start' ? 'ms-[var(--divider-inset,3.5rem)]' : inset === 'both' ? 'mx-[var(--divider-inset,1rem)]' : '';
const spaceClass = vertical === true ? SPACE_X[spacing] : vertical === 'responsive' ? cn(SPACE_Y[spacing], spacing !== 'none' && 'lg:my-0', SPACE_X[spacing].replace('mx-', 'lg:mx-')) : SPACE_Y[spacing];
const hasContent = Boolean(label || icon || ornament || action);
// A bare line: one element with the separator role, a border on whole pixels
if (!hasContent) {
return (
<div
role="separator"
aria-orientation={vertical === true ? 'vertical' : 'horizontal'}
className={cn(
'shrink-0',
vertical === true ? 'w-0 self-stretch border-l' : vertical === 'responsive' ? 'h-0 w-full border-t lg:h-auto lg:w-0 lg:self-stretch lg:border-l lg:border-t-0' : 'h-0 w-auto border-t',
tone === 'fade' ? 'border-transparent' : cn(BORDER_STYLE[line], BORDER_TONE[tone]),
tone === 'fade' && (vertical === true ? 'w-px bg-[linear-gradient(to_bottom,transparent,var(--divider-fade)_20%,var(--divider-fade)_80%,transparent)]' : 'h-px bg-[linear-gradient(to_right,transparent,var(--divider-fade)_20%,var(--divider-fade)_80%,transparent)]'),
vertical === true ? 'min-h-4' : '',
insetClass,
spaceClass,
className,
)}
style={{ ...insetStyle, '--divider-fade': FADE } as React.CSSProperties}
/>
);
}
const middle = action ? (
<ActionButton action={action} />
) : (
<span className={cn('flex shrink-0 items-center gap-2', vertical === true ? 'flex-col' : vertical === 'responsive' && 'lg:flex-col')}>
{icon && (
<span className="grid size-8 place-items-center rounded-full border border-border bg-background text-muted-foreground shadow-[var(--shadow-sm)]">
<Icon name={icon} size={15} />
</span>
)}
{ornament && <span className={cn('text-subtle-foreground', tone === 'accent' && 'text-primary')}>{ORNAMENT[ornament]}</span>}
{label && <span className="text-[12px] font-medium leading-5 text-muted-foreground">{label}</span>}
</span>
);
const before = align !== 'start';
const after = align !== 'end';
return (
<div
// Without a label the drawing is decoration, the whole thing is one separator. With text or a button the content is read, the lines stay silent.
role={label || action ? undefined : 'separator'}
aria-orientation={label || action ? undefined : vertical === true ? 'vertical' : 'horizontal'}
className={cn(
'flex shrink-0 items-center',
vertical === true ? 'min-h-16 flex-col self-stretch gap-2.5' : vertical === 'responsive' ? 'w-full gap-3 lg:w-auto lg:flex-col lg:self-stretch lg:gap-2.5' : 'gap-3',
insetClass,
spaceClass,
className,
)}
style={insetStyle}
>
{before ? <Line vertical={vertical} line={line} tone={tone} fadeTo={tone === 'fade' ? 'start' : undefined} /> : null}
{middle}
{after ? <Line vertical={vertical} line={line} tone={tone} fadeTo={tone === 'fade' ? 'end' : undefined} /> : null}
</div>
);
}
function ActionButton({ action }: { action: DividerAction }) {
const cls =
'relative inline-flex h-8 shrink-0 cursor-pointer select-none items-center gap-1.5 rounded-lg border border-border-strong bg-background px-3 text-[13px] font-medium text-foreground shadow-[var(--shadow-sm)] outline-none transition-[background-color,color,scale] duration-200 ease-[var(--ease-kit)] after:absolute after:-inset-x-1 after:-inset-y-1.5 after:content-[""] hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 motion-reduce:active:scale-100 max-sm:h-9';
const inner = (
<>
{action.icon && <Icon name={action.icon} size={14} className="text-muted-foreground" />}
{action.label}
{action.count !== undefined && <span className="tabular-nums text-subtle-foreground">{action.count}</span>}
</>
);
if (action.href)
return (
<a href={action.href} onClick={action.onClick} className={cls}>
{inner}
</a>
);
return (
<button type="button" onClick={action.onClick} disabled={action.disabled} className={cls}>
{inner}
</button>
);
}Frequent questions
How do I install Divider?
With aiw add @ai-website/divider. The command puts components/ui/divider.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Divider have?
5 variants, With a label, Icon and ornament, With an action, Upright and Inset and dashed.
Can an AI assistant build with Divider?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Divider with aiw_components and install it with aiw_add @ai-website/divider.