Divider
Preview
Basic
Loading…
Preview
Code
ts
import Divider from '@/components/layout/Divider';src/components/layout/Divider.tsx
AI prompt
text
Build a divider (separator rule with optional label) component in React + TypeScript + Tailwind CSS.
## Look
- The rule is a BORDER, not a background — `border-style` is the only way to get dashed and dotted variants that stay crisp at 1px. Colour `border-slate-200 dark:border-slate-700`; `border-t` for horizontal, `border-l` for vertical; `solid` / `dashed` / `dotted`.
- No label: horizontal is `my-4 w-full`; vertical is `mx-3 self-stretch` — it stretches to the height of its flex row, for sitting between inline items.
- With a label: a flex container (horizontal `my-4 w-full gap-2`; vertical `mx-3 flex-col self-stretch gap-1.5`, `items-center`) holding rule · label · rule. Label: `shrink-0 text-[11px] font-medium text-slate-500 dark:text-slate-400`.
- Alignment sets each side's rule: `center` both `flex-1`; `left` (or `top`) a short fixed stub before (`w-4` / `h-3`) and `flex-1` after; `right` (`bottom`) the mirror. The stub keeps a left-aligned label reading as ON the line, not beside it. A value from the other axis falls back to `center`.
## API
`layout?: 'horizontal' | 'vertical'` ('horizontal'), `type?: 'solid' | 'dashed' | 'dotted'` ('solid'), `align?: 'left' | 'center' | 'right' | 'top' | 'bottom'` ('center'), `className`, `children?` (a label: "OR", a section name, an icon). Empty string / false / null count as no label.
## Accessibility
`role="separator"` with `aria-orientation` on the element that draws the rule; the two rule spans are `aria-hidden`. The separator role makes its children presentational, so a plain-string label is ALSO set as `aria-label` — otherwise "Or continue with" is flattened away and the separator is nameless.
## Demo
"Section above", a plain rule, a dashed "OR", a dotted left-aligned "Details", a right-aligned "End of list"; then an `h-16` flex row "Left | Middle ┆or┆ Right" with a solid and a dashed labelled vertical divider.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
import { cn } from '@/lib/cn';
const TYPE = {
solid: 'border-solid',
dashed: 'border-dashed',
dotted: 'border-dotted',
} as const;
/*
* Where the content sits, as the flex-grow of the rule on each side of it. A
* short fixed stub on the near side rather than no rule at all, so a
* left-aligned label still reads as sitting ON the line, not beside it.
*/
const ALIGN = {
left: ['w-4 shrink-0', 'flex-1'],
top: ['h-3 shrink-0', 'flex-1'],
center: ['flex-1', 'flex-1'],
right: ['flex-1', 'w-4 shrink-0'],
bottom: ['flex-1', 'h-3 shrink-0'],
} as const;
export interface DividerProps {
/** `vertical` stretches to the height of its flex row — put it between inline items. */
layout?: 'horizontal' | 'vertical';
type?: keyof typeof TYPE;
/**
* Position of the content along the rule. `left|center|right` for
* horizontal, `top|center|bottom` for vertical; a value from the other axis
* falls back to `center`.
*/
align?: keyof typeof ALIGN;
className?: string;
/** Optional label on the rule ("OR", a section name, an icon). */
children?: React.ReactNode;
}
/**
* A rule between sections, optionally carrying a label.
*
* `role="separator"` with `aria-orientation`, on the element that draws the
* rule. The ARIA separator role makes its children presentational, so a label
* written as a plain string is ALSO passed as `aria-label` — otherwise "Or
* continue with" is flattened away and the separator is announced nameless.
*
* The rules are borders, not backgrounds, because `border-style` is the only
* way to get dashed and dotted variants that stay crisp at 1px.
*/
export default function Divider({
layout = 'horizontal',
type = 'solid',
align = 'center',
className,
children,
}: DividerProps) {
const vertical = layout === 'vertical';
const has = children != null && children !== false && children !== '';
const label = typeof children === 'string' ? children : undefined;
const line = cn(
'border-slate-200 dark:border-slate-700',
TYPE[type],
vertical ? 'border-l' : 'border-t',
);
if (!has) {
return (
<div
role="separator"
aria-orientation={layout}
className={cn(line, vertical ? 'mx-3 self-stretch' : 'my-4 w-full', className)}
/>
);
}
const valid = vertical ? ['top', 'center', 'bottom'] : ['left', 'center', 'right'];
const [before, after] = ALIGN[valid.includes(align) ? align : 'center'];
return (
<div
role="separator"
aria-orientation={layout}
aria-label={label}
className={cn(
'flex items-center',
vertical ? 'mx-3 flex-col self-stretch gap-1.5' : 'my-4 w-full gap-2',
className,
)}
>
<span className={cn(line, before)} aria-hidden />
<span className="shrink-0 text-[11px] font-medium text-slate-500 dark:text-slate-400">{children}</span>
<span className={cn(line, after)} aria-hidden />
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
layout | 'horizontal' | 'vertical' | 'horizontal' | `vertical` stretches to the height of its flex row — put it between inline items. |
type | keyof typeof TYPE | 'solid' | |
align | keyof typeof ALIGN | 'center' | Position of the content along the rule. `left|center|right` for horizontal, `top|center|bottom` for vertical; a value from the other axis falls back to `center`. |
className | string | — | |
children | React.ReactNode | — | Optional label on the rule ("OR", a section name, an icon). |