v1.0

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

PropTypeDefaultDescription
layout'horizontal' | 'vertical''horizontal'`vertical` stretches to the height of its flex row — put it between inline items.
typekeyof typeof TYPE'solid'
alignkeyof 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`.
classNamestring—
childrenReact.ReactNode—Optional label on the rule ("OR", a section name, an icon).