# Seven-Segment Display

> Numbers and clocks on segments you can see even when they're off.

Category: Text. Docs: https://neonkit.heyaadi.com/components/seven-segment

## Installation

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

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/seven-segment`.

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

## Usage

```tsx
import { SevenSegment } from "@/components/ui/seven-segment"

<SevenSegment value="12:45" colonBlink />
```

## Examples

### Clock

Dashes until the first tick, so server and browser render the same thing. The dark leading digit is a space: unlit segments stay faintly visible, like a real display.

```tsx
"use client";

import * as React from "react";
import { SevenSegment } from "@/components/ui/seven-segment";

const pad = (n: number) => String(n).padStart(2, "0");

export default function SevenSegmentDemo() {
  // Dashes until the first tick, so the server and the browser render the same thing.
  const [now, setNow] = React.useState<Date | null>(null);

  React.useEffect(() => {
    let timer: ReturnType<typeof setTimeout>;
    const tick = () => {
      setNow(new Date());
      timer = setTimeout(tick, 1000 - (Date.now() % 1000));
    };
    timer = setTimeout(tick, 0);
    return () => clearTimeout(timer);
  }, []);

  // A 12-hour face: the leading digit stays dark before ten o'clock.
  const hours = now ? now.getHours() % 12 || 12 : 0;
  const time = now ? `${String(hours).padStart(2, " ")}:${pad(now.getMinutes())}` : "--:--";
  const seconds = now ? pad(now.getSeconds()) : "--";
  const pm = now ? now.getHours() >= 12 : false;

  return (
    <div className="relative rounded-2xl border [--neon:var(--neon-accent,34_211_238)] border-white/10 bg-[linear-gradient(to_bottom,#0b0c11,#07080b)] p-2 shadow-[0_30px_60px_-30px_rgb(0_0_0/0.9),inset_0_1px_0_rgb(255_255_255/0.05)]">
      <div className="relative overflow-hidden rounded-xl bg-[#040406] px-6 py-6 shadow-[inset_0_2px_12px_rgb(0_0_0/0.9)] lg:px-10 lg:py-8">
        {/* Light from the segments, spilling onto the smoked glass */}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_70%_60%_at_45%_55%,rgb(var(--neon)/0.12),transparent_70%)]"
        />
        <div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-1/2 bg-[linear-gradient(to_bottom,rgb(255_255_255/0.035),transparent)]" />
        <div className="relative flex items-end gap-3 lg:gap-4">
          <SevenSegment value={time} colonBlink={Boolean(now)} className="[--segment-h:64px] lg:[--segment-h:104px]" />
          <div className="flex flex-col items-start gap-2 pb-1">
            <span className="flex gap-1.5 text-[10px] font-semibold uppercase tracking-[0.14em]">
              <span className={pm ? "text-white/25" : "text-[rgb(var(--neon))] [text-shadow:0_0_8px_rgb(var(--neon)/0.8)]"}>am</span>
              <span className={pm ? "text-[rgb(var(--neon))] [text-shadow:0_0_8px_rgb(var(--neon)/0.8)]" : "text-white/25"}>pm</span>
            </span>
            <SevenSegment value={seconds} className="[--segment-h:28px] lg:[--segment-h:44px]" />
          </div>
        </div>
      </div>
    </div>
  );
}
```

### Scoreboard

digits={2} pads scores with unlit digits so the layout never shifts.

```tsx
"use client";

import * as React from "react";
import { Minus, Plus } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { SevenSegment } from "@/components/ui/seven-segment";

function Team({
  name,
  score,
  onChange,
}: {
  name: string;
  score: number;
  onChange: (score: number) => void;
}) {
  return (
    <div className="flex flex-col items-center gap-3">
      <span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/60">{name}</span>
      <SevenSegment
        value={score}
        digits={2}
        color="red"
        aria-label={`${name} ${score}`}
        className="[--segment-h:52px] lg:[--segment-h:72px]"
      />
      <div className="flex gap-1">
        <Button size="icon" variant="outline" className="size-8" aria-label={`${name} minus one`} onClick={() => onChange(Math.max(0, score - 1))}>
          <Minus />
        </Button>
        <Button size="icon" variant="outline" className="size-8" aria-label={`${name} plus one`} onClick={() => onChange(Math.min(99, score + 1))}>
          <Plus />
        </Button>
      </div>
    </div>
  );
}

export default function SevenSegmentScoreboard() {
  const [home, setHome] = React.useState(3);
  const [away, setAway] = React.useState(1);

  return (
    <div className="grid w-full max-w-lg grid-cols-[1fr_auto_1fr] items-start gap-3 rounded-2xl border border-white/10 bg-[#07080b] px-3 py-6 lg:gap-4 lg:px-8">
      <Team name="Harbour" score={home} onChange={setHome} />
      <div className="flex flex-col items-center gap-3 pt-7">
        <SevenSegment
          value="12:48"
          color="amber"
          aria-label="12 minutes 48 seconds left"
          className="[--segment-h:22px] lg:[--segment-h:30px]"
        />
        <div className="flex items-center gap-2">
          <span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-white/50">Period</span>
          <SevenSegment value={3} size={20} color="amber" />
        </div>
      </div>
      <Team name="Visitors" score={away} onChange={setAway} />
    </div>
  );
}
```

### Stopwatch

A point belongs to the digit before it, as on hardware, so 00:00.00 still has six digit cells.

```tsx
"use client";

import * as React from "react";
import { Flag, Pause, Play, RotateCcw } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { SevenSegment } from "@/components/ui/seven-segment";

function format(ms: number) {
  const minutes = Math.floor(ms / 60000);
  const seconds = Math.floor((ms % 60000) / 1000);
  const hundredths = Math.floor((ms % 1000) / 10);
  return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(hundredths).padStart(2, "0")}`;
}

export default function SevenSegmentStopwatch() {
  const [elapsed, setElapsed] = React.useState(0);
  const [running, setRunning] = React.useState(false);
  const [laps, setLaps] = React.useState<number[]>([]);
  const started = React.useRef(0);

  React.useEffect(() => {
    if (!running) return;
    started.current = performance.now() - elapsed;
    // 30 updates a second is plenty for hundredths; the eye can't follow more.
    const id = setInterval(() => setElapsed(performance.now() - started.current), 33);
    return () => clearInterval(id);
    // elapsed is read once when the run starts.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [running]);

  return (
    <div className="flex w-full max-w-sm flex-col items-center gap-6">
      <SevenSegment value={format(elapsed)} size={56} aria-live="off" />
      <div className="flex gap-2">
        <Button variant={running ? "outline" : "solid"} onClick={() => setRunning((value) => !value)}>
          {running ? <Pause /> : <Play />} {running ? "Stop" : "Start"}
        </Button>
        {running ? (
          <Button variant="ghost" onClick={() => setLaps((list) => [elapsed, ...list].slice(0, 4))}>
            <Flag /> Lap
          </Button>
        ) : (
          <Button
            variant="ghost"
            disabled={elapsed === 0}
            onClick={() => {
              setElapsed(0);
              setLaps([]);
            }}
          >
            <RotateCcw /> Reset
          </Button>
        )}
      </div>
      {laps.length > 0 && (
        <ol className="w-full divide-y divide-white/[0.06] border-y border-white/[0.06]">
          {laps.map((lap, index) => (
            <li key={lap} className="flex items-center justify-between py-2">
              <span className="text-[13px] text-white/55">Lap {laps.length - index}</span>
              <SevenSegment value={format(lap)} size={18} unlit={0.05} />
            </li>
          ))}
        </ol>
      )}
    </div>
  );
}
```

### Characters and options

Hex digits, a few extra letters, padding, skew={0} and a brighter unlit level.

```tsx
import { SevenSegment } from "@/components/ui/seven-segment";

export default function SevenSegmentCharacters() {
  return (
    <div className="flex flex-col items-center gap-5">
      <SevenSegment value="0123456789" size={40} />
      <SevenSegment value="AbCdEF -" size={40} color="lime" />
      <div className="flex flex-wrap items-center justify-center gap-6">
        <SevenSegment value="-12.5" digits={4} size={40} color="orange" aria-label="minus 12.5 degrees" />
        <SevenSegment value="HELP" size={40} color="red" skew={0} />
        <SevenSegment value="88:88" size={40} color="violet" unlit={0.18} />
      </div>
    </div>
  );
}
```

## API reference

### SevenSegment

Renders a <span role="img"> labelled with the value, holding two stacked SVGs: unlit glass and lit segments.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string \| number` | — | 0–9, A–F, minus, colon, dot and space, plus H, J, L, P, U, n, o, r, t, u and y. Unknown characters render as a blank digit. |
| `digits` | `number` | `0` | Pads on the left with unlit digits up to this count. Colons and dots don't count. |
| `size` | `"sm" \| "md" \| "lg" \| number` | `"md"` | Digit height: 32, 48 or 72px, or any pixel value. It sets --segment-h, so responsive classes like lg:[--segment-h:96px] work too. |
| `skew` | `number` | `8` | Forward lean in degrees. 0 stands the digits upright. |
| `unlit` | `number` | `0.08` | Opacity of unlit segments, from 0 (invisible) to 1. |
| `colonBlink` | `boolean` | `false` | Blink colons once a second. Holds steady with reduced motion. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

## Accessibility

- The display is role="img" labelled with the value as written. Pass aria-label for something more natural, like "12 minutes 48 seconds left".
- Decimal-point slots only appear when the value contains a point, so clocks and scores stay tight.
- Segments switch on in 50ms and fade out over 160ms, like phosphor. Both are instant with reduced motion.

## Source

`components/ui/seven-segment.tsx`

```tsx
"use client";

import * as React from "react";

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

// Geometry in viewBox units. A digit is 56 × 100 with 11-unit segments;
// the pointed (mitred) ends leave a hairline seam where segments meet.
const DW = 56;
const DH = 100;
const T = 11;
const SEAM = 1.6;
const DP = 15; // room after each digit for its decimal point
const COLON = 22;
const PITCH = 10; // space between characters

const h = T / 2;

function horizontal(y: number) {
  const x1 = h + SEAM;
  const x2 = DW - h - SEAM;
  return `${x1},${y} ${x1 + h},${y - h} ${x2 - h},${y - h} ${x2},${y} ${x2 - h},${y + h} ${x1 + h},${y + h}`;
}

function vertical(x: number, y1: number, y2: number) {
  return `${x},${y1} ${x + h},${y1 + h} ${x + h},${y2 - h} ${x},${y2} ${x - h},${y2 - h} ${x - h},${y1 + h}`;
}

const mid = DH / 2;
const segments = [
  horizontal(h), // a
  vertical(DW - h, h + SEAM, mid - SEAM), // b
  vertical(DW - h, mid + SEAM, DH - h - SEAM), // c
  horizontal(DH - h), // d
  vertical(h, mid + SEAM, DH - h - SEAM), // e
  vertical(h, h + SEAM, mid - SEAM), // f
  horizontal(mid), // g
];

// Bit order: a b c d e f g.
const glyphs: Record<string, string> = {
  "0": "1111110",
  "1": "0110000",
  "2": "1101101",
  "3": "1111001",
  "4": "0110011",
  "5": "1011011",
  "6": "1011111",
  "7": "1110000",
  "8": "1111111",
  "9": "1111011",
  A: "1110111",
  B: "0011111",
  C: "1001110",
  D: "0111101",
  E: "1001111",
  F: "1000111",
  G: "1011110",
  H: "0110111",
  I: "0110000",
  J: "0111100",
  L: "0001110",
  N: "0010101",
  O: "1111110",
  P: "1100111",
  R: "0000101",
  S: "1011011",
  T: "0001111",
  U: "0111110",
  Y: "0111011",
  "-": "0000001",
  _: "0001000",
  " ": "0000000",
};
// Lowercase forms that read differently on seven segments.
const lower: Record<string, string> = { h: "0010111", o: "0011101", u: "0011100", c: "0001101" };

function maskFor(char: string) {
  return (lower[char] ?? glyphs[char.toUpperCase()] ?? glyphs[" "]).split("").map((bit) => bit === "1");
}

type Token = { kind: "digit"; mask: boolean[]; dot: boolean } | { kind: "colon" };

function tokenize(text: string, digits: number) {
  const tokens: Token[] = [];
  for (const char of Array.from(text)) {
    const previous = tokens[tokens.length - 1];
    if (char === "." || char === ",") {
      // The point belongs to the digit before it, like on real hardware.
      if (previous?.kind === "digit" && !previous.dot) previous.dot = true;
      else tokens.push({ kind: "digit", mask: maskFor(" "), dot: true });
    } else if (char === ":") {
      tokens.push({ kind: "colon" });
    } else {
      tokens.push({ kind: "digit", mask: maskFor(char), dot: false });
    }
  }
  const count = tokens.filter((token) => token.kind === "digit").length;
  const pad: Token[] = Array.from({ length: Math.max(0, digits - count) }, () => ({
    kind: "digit",
    mask: maskFor(" "),
    dot: false,
  }));
  return [...pad, ...tokens];
}

// Digit height lives in one variable, so `lg:[--segment-h:96px]` resizes a display.
const H = "var(--segment-h,48px)";
const sizes = { sm: "[--segment-h:32px]", md: "[--segment-h:48px]", lg: "[--segment-h:72px]" } as const;

const css = `
@keyframes nk-seven-segment-blink { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }
.nk-seven-segment-blink { animation: nk-seven-segment-blink 1s steps(1, end) infinite; }
.nk-seven-segment-lit [data-on] { transition: opacity 50ms linear; }
.nk-seven-segment-lit [data-off] { transition: opacity 160ms ease-out; }
@media (prefers-reduced-motion: reduce) {
  .nk-seven-segment-blink { animation: none; }
  .nk-seven-segment-lit [data-on], .nk-seven-segment-lit [data-off] { transition: none; }
}
`;

export interface SevenSegmentProps
  extends Omit<React.HTMLAttributes<HTMLSpanElement>, "color" | "children"> {
  /** What to show: 0–9, A–F (plus H J L P U n o r t u y), minus, colon, dot and space. */
  value: string | number;
  /** Pads on the left with unlit digits up to this count. Colons and dots don't count. */
  digits?: number;
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** Digit height: 32, 48 or 72px, or any pixel value. Also settable with a `--segment-h` class. */
  size?: keyof typeof sizes | number;
  /** Forward lean in degrees. */
  skew?: number;
  /** Opacity of unlit segments, 0 to 1. */
  unlit?: number;
  /** Blink colons once a second, like a clock. */
  colonBlink?: boolean;
}

const SevenSegment = React.forwardRef<HTMLSpanElement, SevenSegmentProps>(
  (
    {
      className,
      style,
      value,
      digits = 0,
      color,
      size = "md",
      skew = 8,
      unlit = 0.08,
      colonBlink = false,
      ...props
    },
    ref,
  ) => {
    const text = String(value);
    const tokens = tokenize(text, digits);
    const lean = Math.tan((skew * Math.PI) / 180) * DH;
    // Decimal-point slots only appear when the value has a point, so clocks stay tight.
    const hasPoints = /[.,]/.test(text);
    const advance = DW + (hasPoints ? DP : 0);

    let x = 0;
    const placed = tokens.map((token) => {
      const at = x;
      x += (token.kind === "colon" ? COLON : advance) + PITCH;
      return { token, at };
    });
    const width = Math.max(x - PITCH + Math.abs(lean), 1);
    const viewBox = `0 0 ${width} ${DH}`;
    const transform = `translate(${Math.max(0, lean)} 0) skewX(${-skew})`;
    const box = { width: `calc(${H} * ${(width / DH).toFixed(4)})`, height: H };

    const shapes = (lit: boolean) =>
      placed.map(({ token, at }, index) => {
        if (token.kind === "colon") {
          return (
            <g
              key={index}
              data-colon=""
              className={lit && colonBlink ? "nk-seven-segment-blink" : undefined}
              transform={`translate(${at + COLON / 2} 0)`}
            >
              <rect x={-h} y={DH * 0.3 - h} width={T} height={T} rx={1.5} />
              <rect x={-h} y={DH * 0.7 - h} width={T} height={T} rx={1.5} />
            </g>
          );
        }
        return (
          <g key={index} data-digit="" transform={`translate(${at} 0)`}>
            {segments.map((shape, s) => (
              <polygon
                key={s}
                points={shape}
                data-on={lit && token.mask[s] ? "" : undefined}
                data-off={lit && !token.mask[s] ? "" : undefined}
                opacity={lit ? (token.mask[s] ? 1 : 0) : undefined}
              />
            ))}
            {hasPoints && (
              <circle
                cx={DW + DP / 2}
                cy={DH - h}
                r={h * 0.95}
                data-on={lit && token.dot ? "" : undefined}
                data-off={lit && !token.dot ? "" : undefined}
                opacity={lit ? (token.dot ? 1 : 0) : undefined}
              />
            )}
          </g>
        );
      });

    const glow = `drop-shadow(0 0 calc(${H} * 0.03) rgb(var(--neon) / var(--neon-glow, 1))) drop-shadow(0 0 calc(${H} * 0.14) rgb(var(--neon) / calc(0.55 * var(--neon-glow, 1))))`;

    return (
      <span
        ref={ref}
        role="img"
        aria-label={text}
        style={neonVars(
          color,
          typeof size === "number" ? ({ "--segment-h": `${size}px`, ...style } as React.CSSProperties) : style,
        )}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "relative inline-block shrink-0 align-middle",
          typeof size === "string" && sizes[size],
          className,
        )}
        {...props}
      >
        <style>{css}</style>
        <svg
          aria-hidden
          viewBox={viewBox}
          className="block overflow-visible"
          style={{ ...box, fill: `rgb(var(--neon) / ${unlit})` }}
        >
          <g transform={transform}>{shapes(false)}</g>
        </svg>
        <svg
          aria-hidden
          viewBox={viewBox}
          className="nk-seven-segment-lit absolute inset-0 block overflow-visible"
          style={{ ...box, fill: "color-mix(in srgb, rgb(var(--neon)) 72%, white)", filter: glow }}
        >
          <g transform={transform}>{shapes(true)}</g>
        </svg>
      </span>
    );
  },
);
SevenSegment.displayName = "SevenSegment";

export { SevenSegment };
```
