Skip to content
UI Kit
Accent color

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.

5 variantsOne of 9 components in Data display

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

With a labelNew in 1.2

The label sits in the middle, at the start or at the end: between two ways to sign in, as a date in a chat, as the head of a quiet area in settings.

Icon and ornamentNew in 1.2

A scene break in a long text, a mark above a title, a wave or a diamond to close a list. The line fades into the surface where it should feel light.

With an actionNew in 1.2

The line carries a button: earlier replies above a thread, more activity at the end of a feed. Press them, the new rows rise in and the line says when everything is there.

UprightNew in 1.2

Upright in a toolbar and between meta data, between key figures that stack on phones, and between two columns, where the same line lies flat below 1024 px.

Inset and dashedNew in 1.2

In a list the line starts where the text starts, after the avatar. On a receipt dashed and dotted lines keep the inner margin and leave the edges free.

Details

What Divider brings along, read from the registry and the source.

Category
Data display
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

Props

The DividerProps interface from components/ui/divider.tsx, with the comments from the code.

  • orientationoptional
    DividerOrientation

    horizontalverticalresponsive

    responsive: lies flat on phones and tablets, stands upright from 1024 px on (two columns side by side)

  • labeloptional
    React.ReactNode

    Text 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

  • iconoptional
    IconName
  • ornamentoptional
    DividerOrnament

    dotsdiamondasterismwave

  • actionoptional
    DividerAction

    A button or link in the line, e.g. "Load more"

  • lineoptional
    DividerLine

    soliddasheddotted

  • toneoptional
    DividerTone

    defaultstrongaccentfade

  • insetoptional
    'none' | 'start' | 'both' | number

    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.

  • 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
components/ui/divider.tsx
'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.

Often searched

Jump to

Copied to the clipboard