# Separator

> Hairlines and tube dividers, with optional labels.

Category: Layout. Docs: https://neonkit.heyaadi.com/components/separator

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/separator.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/separator`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { Separator } from "@/components/ui/separator"

<Separator variant="tube" label="2 new messages" decorative={false} />
```

## Examples

### Unread marker

The tube is for the one line that matters: here, where unread messages start. With decorative={false} it is announced, named by its label.

```tsx
import { Separator } from "@/components/ui/separator";

type Message = { author: string; initials: string; time: string; text: string };

const yesterday: Message[] = [
  { author: "Mara Okafor", initials: "MO", time: "6:42 PM", text: "Pushed the new pricing table to staging. Can someone sanity-check the annual toggle?" },
  { author: "Theo Lindqvist", initials: "TL", time: "7:05 PM", text: "Looks right to me. The yearly total rounds down on the Team plan though." },
];

const unread: Message[] = [
  { author: "Mara Okafor", initials: "MO", time: "9:12 AM", text: "Fixed the rounding. Totals now come straight from the billing API." },
  { author: "Iris Chen", initials: "IC", time: "9:20 AM", text: "Approved. Shipping it with the 2.4 release this afternoon." },
];

function Thread({ messages }: { messages: Message[] }) {
  return (
    <ul className="grid gap-4">
      {messages.map((message) => (
        <li key={message.time} className="flex gap-3">
          <span
            aria-hidden
            className="grid size-8 shrink-0 place-items-center rounded-full bg-white/[0.06] text-[11px] font-medium text-white/70"
          >
            {message.initials}
          </span>
          <div className="min-w-0">
            <p className="text-sm">
              <span className="font-medium text-white/90">{message.author}</span>{" "}
              <span className="text-xs text-white/40">{message.time}</span>
            </p>
            <p className="text-sm leading-6 text-white/65">{message.text}</p>
          </div>
        </li>
      ))}
    </ul>
  );
}

export default function SeparatorDemo() {
  return (
    <div className="grid w-full max-w-md gap-5">
      <Separator label="Yesterday" decorative={false} />
      <Thread messages={yesterday} />
      <Separator variant="tube" label="2 new messages" decorative={false} />
      <Thread messages={unread} />
    </div>
  );
}
```

### Variants

Hairline is the default. Dashed uses a gradient so the dashes match in every browser.

```tsx
import { Separator } from "@/components/ui/separator";

const variants = [
  { variant: "hairline", note: "Quiet structure between sections." },
  { variant: "dashed", note: "Softer, for optional or draft content." },
  { variant: "tube", note: "A mounted neon tube. Save it for one place." },
] as const;

export default function SeparatorVariants() {
  return (
    <div className="grid w-full max-w-md gap-8">
      {variants.map(({ variant, note }) => (
        <div key={variant} className="grid gap-3">
          <div className="flex items-baseline justify-between gap-4">
            <span className="font-mono text-xs text-white/70">{variant}</span>
            <span className="text-xs text-white/40">{note}</span>
          </div>
          <Separator variant={variant} />
        </div>
      ))}
    </div>
  );
}
```

### Vertical

In a flex row a vertical separator stretches to the row's height.

```tsx
import { Separator } from "@/components/ui/separator";

const stats = [
  { label: "Requests", value: "1.28M" },
  { label: "Error rate", value: "0.04%" },
  { label: "p95 latency", value: "182 ms" },
];

export default function SeparatorVertical() {
  return (
    <div className="grid w-full max-w-md gap-8">
      <div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-white/55 [&>span]:whitespace-nowrap">
        <span className="font-medium text-white/85">neonkit@1.1.0</span>
        <Separator orientation="vertical" className="h-3.5 self-center" />
        <span>MIT</span>
        <Separator orientation="vertical" className="h-3.5 self-center" />
        <span>4.2 kB gzipped</span>
        {/* On a phone the date wraps to its own line, so it doesn't need a rule before it. */}
        <Separator orientation="vertical" className="hidden h-3.5 self-center sm:block" />
        <span>Published 3 days ago</span>
      </div>

      <div className="flex items-stretch rounded-xl border border-white/10 bg-white/[0.03] py-4">
        {stats.map((stat, index) => (
          <div key={stat.label} className="flex min-w-0 flex-1 items-stretch">
            {index > 0 && <Separator orientation="vertical" />}
            <div className="grid min-w-0 flex-1 gap-1 px-4 sm:px-5">
              <span className="truncate text-xs text-white/50">{stat.label}</span>
              <span className="whitespace-nowrap text-base font-semibold tabular-nums text-white/90 sm:text-lg">
                {stat.value}
              </span>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}
```

### Label on a hairline

Most dividers should stay quiet. The label breaks the line and centers itself.

```tsx
import { Fingerprint, KeyRound } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";

export default function SeparatorSignIn() {
  return (
    <div className="grid w-full max-w-sm gap-6 rounded-xl border border-white/10 bg-white/[0.03] p-6">
      <div className="grid gap-1">
        <h3 className="text-base font-semibold text-white/90">Sign in to Halcyon</h3>
        <p className="text-sm text-white/55">Use your company identity provider or a passkey.</p>
      </div>
      <div className="grid gap-2">
        <Button variant="ghost" className="border border-white/10 bg-white/[0.03] text-white/80">
          <KeyRound /> Continue with SSO
        </Button>
        <Button variant="ghost" className="border border-white/10 bg-white/[0.03] text-white/80">
          <Fingerprint /> Use a passkey
        </Button>
      </div>
      <Separator label="or" />
      <div className="grid gap-3">
        <Input type="email" label="Work email" placeholder="ada@halcyon.dev" autoComplete="email" />
        <Button variant="soft">Email me a sign-in link</Button>
      </div>
    </div>
  );
}
```

## API reference

### Separator

Also accepts native div attributes. Without a label, hairline and dashed render a single element, so className restyles the line itself.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"hairline" \| "tube" \| "dashed"` | `"hairline"` | A quiet line, a lit tube with end caps, or 4px dashes. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Horizontal fills the width. Vertical stretches to the height of a flex row. |
| `label` | `ReactNode` | — | Text centered in the line. The line breaks around it. |
| `decorative` | `boolean` | `true` | Hidden from assistive tech when true. Set false when it separates real sections. |
| `color` | `NeonColor` | `inherited` | Color of the tube and its label. Falls back to --neon-accent, then cyan. |

## Accessibility

- Decorative separators render role="none", so screen readers skip the line but still read a label as plain text.
- With decorative={false} it renders role="separator" with aria-orientation, and a label becomes its accessible name through aria-labelledby.
- A vertical separator needs a flex row to stretch in. Anywhere else, give it a height with className.

## Source

`components/ui/separator.tsx`

```tsx
import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

type Orientation = "horizontal" | "vertical";
type Variant = "hairline" | "tube" | "dashed";

const lines = {
  hairline: {
    horizontal: "h-px bg-white/10",
    vertical: "w-px bg-white/10",
  },
  // A gradient instead of border-dashed, so the dashes are the same 4px on/off in every browser.
  dashed: {
    horizontal: "h-px bg-[repeating-linear-gradient(90deg,rgb(255_255_255/0.18)_0_4px,transparent_4px_8px)]",
    vertical: "w-px bg-[repeating-linear-gradient(180deg,rgb(255_255_255/0.18)_0_4px,transparent_4px_8px)]",
  },
} as const;

// The electrode housings at each end of a mounted tube. A little of the tube's
// light spills onto the face of each cap that points at the glass.
const caps = {
  horizontal: {
    base: "h-1.5 w-[5px] rounded-[1.5px] bg-[linear-gradient(180deg,#4a4e5a,#1d1f26)]",
    start: "shadow-[inset_0_1px_0_rgb(255_255_255/0.18),3px_0_6px_-2px_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1)))]",
    end: "shadow-[inset_0_1px_0_rgb(255_255_255/0.18),-3px_0_6px_-2px_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1)))]",
  },
  vertical: {
    base: "h-[5px] w-1.5 rounded-[1.5px] bg-[linear-gradient(90deg,#4a4e5a,#1d1f26)]",
    start: "shadow-[inset_1px_0_0_rgb(255_255_255/0.18),0_3px_6px_-2px_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1)))]",
    end: "shadow-[inset_1px_0_0_rgb(255_255_255/0.18),0_-3px_6px_-2px_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1)))]",
  },
} as const;

const glass = {
  horizontal: "h-0.5 min-w-2",
  vertical: "w-0.5 min-h-2",
} as const;

export interface SeparatorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Palette name or any hex color for the tube. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  orientation?: Orientation;
  variant?: Variant;
  /** Text centered in the line. The line breaks around it. */
  label?: React.ReactNode;
  /**
   * Purely visual (the default): hidden from assistive tech, though a label is still read as text.
   * Set false when it separates real sections, to expose `role="separator"`.
   */
  decorative?: boolean;
}

function Tube({ orientation }: { orientation: Orientation }) {
  const c = caps[orientation];
  return (
    <span
      aria-hidden
      className={cn(
        "flex min-h-0 min-w-0 flex-1 items-center",
        orientation === "vertical" && "flex-col",
      )}
    >
      <span className={cn("shrink-0", c.base, c.start)} />
      <span
        className={cn(
          "flex-1 rounded-full bg-[rgb(var(--neon))]",
          "shadow-[0_0_2px_rgb(var(--neon)/var(--neon-glow,1)),0_0_8px_rgb(var(--neon)/CALC(0.55*var(--neon-glow,1))),0_0_18px_rgb(var(--neon)/CALC(0.22*var(--neon-glow,1)))]",
          glass[orientation],
        )}
      />
      <span className={cn("shrink-0", c.base, c.end)} />
    </span>
  );
}

function Line({ orientation, variant }: { orientation: Orientation; variant: Variant }) {
  if (variant === "tube") return <Tube orientation={orientation} />;
  return (
    <span
      aria-hidden
      className={cn("min-h-0 min-w-0 flex-1", lines[variant][orientation])}
    />
  );
}

const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
  (
    {
      className,
      style,
      color,
      orientation = "horizontal",
      variant = "hairline",
      label,
      decorative = true,
      ...props
    },
    ref,
  ) => {
    const labelId = React.useId();
    const vertical = orientation === "vertical";
    const hasLabel = label != null && label !== false;

    const semantics = decorative
      ? { role: "none" as const }
      : {
          role: "separator" as const,
          "aria-orientation": orientation,
          "aria-labelledby": hasLabel ? labelId : undefined,
        };

    const base = cn(
      "[--neon:var(--neon-accent,34_211_238)]",
      "shrink-0",
      // In a flex row a vertical separator stretches to the row's height.
      vertical ? "self-stretch" : "w-full",
    );

    // A plain line is a single element, so className can restyle it directly.
    if (!hasLabel && variant !== "tube") {
      return (
        <div
          ref={ref}
          data-orientation={orientation}
          data-variant={variant}
          style={neonVars(color, style)}
          className={cn(base, lines[variant][orientation], className)}
          {...semantics}
          {...props}
        />
      );
    }

    return (
      <div
        ref={ref}
        data-orientation={orientation}
        data-variant={variant}
        style={neonVars(color, style)}
        className={cn(
          base,
          "flex items-center",
          vertical && "flex-col",
          hasLabel && (variant === "tube" ? "gap-2.5" : "gap-3"),
          className,
        )}
        {...semantics}
        {...props}
      >
        <Line orientation={orientation} variant={variant} />
        {hasLabel && (
          <>
            <span
              id={labelId}
              className={cn(
                "shrink-0 text-xs leading-4",
                variant === "tube"
                  ? "font-medium text-[rgb(var(--neon))] [text-shadow:0_0_10px_rgb(var(--neon)/CALC(0.55*var(--neon-glow,1)))]"
                  : "text-white/50",
              )}
            >
              {label}
            </span>
            <Line orientation={orientation} variant={variant} />
          </>
        )}
      </div>
    );
  },
);
Separator.displayName = "Separator";

export { Separator };
```
