# Neon Frame

> A HUD frame with corner brackets that draw in and label tabs.

Category: Effects. Docs: https://neonkit.heyaadi.com/components/neon-frame

## Installation

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

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/neon-frame`.

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

## Usage

```tsx
import { NeonFrame } from "@/components/ui/neon-frame"

<NeonFrame as="section" label="Relay KX-7" tabs={{ topRight: "Ch 04" }}>
  …
</NeonFrame>
```

## Examples

### Telemetry panel

When the frame scrolls into view, light runs out of each corner along the bracket glass, then the tabs settle onto the edges. The scan line sweeps every few seconds.

```tsx
import { NeonFrame } from "@/components/ui/neon-frame";
import { NeonIcon } from "@/components/ui/neon-icon";
import { Sparkline } from "@/components/ui/glow-chart";

// Uplink throughput over the last two minutes, in Mbps, sampled every 10s.
const throughput = [366, 378, 371, 392, 405, 388, 397, 414, 402, 419, 408, 412];

const readings = [
  { label: "Latency", value: "38", unit: "ms" },
  { label: "Packet loss", value: "0.02", unit: "%" },
  { label: "Handshake", value: "4", unit: "s ago" },
];

export default function NeonFrameDemo() {
  return (
    <NeonFrame
      as="section"
      label="Relay KX-7"
      tabs={{
        topRight: "Ch 04",
        bottomLeft: <span className="font-mono tabular-nums">52.5200 N · 13.4050 E</span>,
        bottomRight: "Live",
      }}
      scanline
      className="w-full max-w-lg px-6 pb-8 pt-7"
    >
      <div className="flex items-start justify-between gap-4">
        <div>
          <p className="text-sm text-white/55">Uplink</p>
          <p className="mt-1.5 text-4xl font-semibold tracking-[-0.02em] text-white">
            412 <span className="text-base font-normal text-white/55">Mbps</span>
          </p>
        </div>
        <NeonIcon icon="signal" value={0.75} size={28} label="Signal: 3 of 4 bars, −67 dBm" className="mt-1" />
      </div>

      <Sparkline data={throughput} width="100%" height={44} live className="mt-4" label="Uplink throughput, last 2 minutes" />

      <dl className="mt-6 grid grid-cols-3 gap-4 border-t border-white/[0.06] pt-5">
        {readings.map((reading) => (
          <div key={reading.label} className="grid gap-1">
            <dt className="text-xs text-white/55">{reading.label}</dt>
            <dd className="text-lg font-medium text-white/90">
              {reading.value} <span className="text-xs font-normal text-white/55">{reading.unit}</span>
            </dd>
          </div>
        ))}
      </dl>
    </NeonFrame>
  );
}
```

### Pick a target

active locks the chosen frame on: its brackets tighten and brighten. active={false} parks the others in standby, as unlit glass that lights on hover and focus. Native radios keep the arrow keys.

```tsx
"use client";

import * as React from "react";
import { NeonFrame } from "@/components/ui/neon-frame";

const regions = [
  { id: "fra1", city: "Frankfurt", latency: 18, note: "Closest to most of your users" },
  { id: "iad1", city: "Washington, D.C.", latency: 92, note: "Where your database lives" },
  { id: "hnd1", city: "Tokyo", latency: 241, note: "Lowest cost per request" },
];

export default function NeonFrameRegions() {
  const [region, setRegion] = React.useState("fra1");

  return (
    <fieldset className="grid w-full max-w-md gap-3">
      <legend className="mb-3 text-sm font-medium text-white/90">Deploy region</legend>
      {regions.map((r) => (
        <NeonFrame key={r.id} active={region === r.id} bracket={12} animate={false} className="p-0 has-[:focus-visible]:border-white/20">
          <label className="flex cursor-pointer items-center gap-4 px-4 py-3.5">
            <input
              type="radio"
              name="region"
              value={r.id}
              checked={region === r.id}
              onChange={() => setRegion(r.id)}
              className="peer sr-only"
            />
            <span className="w-9 font-mono text-xs text-white/55 peer-checked:text-[rgb(var(--neon))]">
              {r.id}
            </span>
            <span className="min-w-0 flex-1">
              <span className="block text-sm text-white/90">{r.city}</span>
              <span className="block text-xs text-white/55">{r.note}</span>
            </span>
            <span className="text-sm tabular-nums text-white/70">{r.latency} ms</span>
          </label>
        </NeonFrame>
      ))}
    </fieldset>
  );
}
```

### Tabs on every corner

As a figure, the label names it. Tabs take any content, like a recording dot. A small amber frame inside marks a detection.

```tsx
import { NeonFrame } from "@/components/ui/neon-frame";

// A night feed, drawn flat and desaturated like a CCTV sensor sees it.
function DockScene() {
  const floor = [-60, -20, 20, 60, 100, 140, 180, 220];
  return (
    <svg aria-hidden viewBox="0 0 160 90" preserveAspectRatio="xMidYMid slice" className="absolute inset-0 size-full">
      <defs>
        <radialGradient id="dock-lamp" cx="0.29" cy="0.12" r="0.45">
          <stop offset="0" stopColor="#d9c9a0" stopOpacity="0.32" />
          <stop offset="1" stopColor="#d9c9a0" stopOpacity="0" />
        </radialGradient>
        <linearGradient id="dock-floor" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#141b23" />
          <stop offset="1" stopColor="#0b0f14" />
        </linearGradient>
        <radialGradient id="dock-vignette" cx="0.5" cy="0.5" r="0.75">
          <stop offset="0.55" stopColor="#000" stopOpacity="0" />
          <stop offset="1" stopColor="#000" stopOpacity="0.6" />
        </radialGradient>
      </defs>
      <rect width="160" height="62" fill="#10161d" />
      <rect y="62" width="160" height="28" fill="url(#dock-floor)" />
      {/* Floor joints running back to the door */}
      <g stroke="#26313d" strokeWidth="0.35">
        {floor.map((x) => (
          <line key={x} x1={x} y1="90" x2={46 + (x - 46) * 0.12} y2="62" />
        ))}
        <line x1="0" y1="70" x2="160" y2="70" />
        <line x1="0" y1="80" x2="160" y2="80" />
      </g>
      {/* Roller door, half open, and the lamp over it */}
      <rect x="22" y="18" width="48" height="44" fill="#151c24" stroke="#2a3542" strokeWidth="0.5" />
      <g stroke="#222c37" strokeWidth="0.45">
        {Array.from({ length: 9 }, (_, i) => (
          <line key={i} x1="22" y1={20 + i * 3} x2="70" y2={20 + i * 3} />
        ))}
      </g>
      <rect x="22.25" y="47" width="47.5" height="15" fill="#06080b" />
      <rect x="40" y="13" width="12" height="2.2" rx="1" fill="#e8dcb8" opacity="0.85" />
      <rect width="160" height="90" fill="url(#dock-lamp)" />
      {/* Pallets by the door */}
      <g fill="#1c2530" stroke="#2c3946" strokeWidth="0.4">
        <rect x="6" y="56" width="13" height="8" />
        <rect x="6" y="48" width="13" height="8" />
        <rect x="8" y="64" width="13" height="8" />
      </g>
      {/* Forklift, side on, forks toward the door */}
      <g stroke="#3a4957" strokeWidth="0.6" strokeLinejoin="round">
        <path d="M96 30v44M99 30v44" />
        <path d="M86 73.5h12" strokeWidth="1.2" />
        <rect x="87" y="66" width="10" height="6.5" fill="#1f2a35" />
        <path d="M103 52l2-15h17l2 15" fill="none" />
        <path d="M100 52h26a2 2 0 0 1 2 2v14h-28z" fill="#232f3b" />
        <path d="M122 52h6v16h-6z" fill="#2b3845" />
        <circle cx="105" cy="74" r="5" fill="#0d1218" />
        <circle cx="122" cy="74" r="5" fill="#0d1218" />
        <circle cx="105" cy="74" r="1.6" fill="#3a4957" stroke="none" />
        <circle cx="122" cy="74" r="1.6" fill="#3a4957" stroke="none" />
      </g>
      <rect x="114" y="36.3" width="5" height="1.2" fill="#e6a23c" opacity="0.8" />
      <rect width="160" height="90" fill="url(#dock-vignette)" />
    </svg>
  );
}

export default function NeonFrameCamera() {
  return (
    <NeonFrame
      as="figure"
      label="Cam 03, loading dock"
      tabs={{
        topRight: (
          <>
            <span aria-hidden className="size-1.5 animate-pulse rounded-full bg-[rgb(255_77_94)] shadow-[0_0_6px_rgb(255_77_94)]" />
            Rec
          </>
        ),
        bottomLeft: <span className="font-mono tabular-nums">2026-10-10 23:41:07</span>,
        bottomRight: "1080p · 24 fps",
      }}
      className="w-full max-w-xl p-2"
    >
      <div className="relative aspect-video overflow-hidden bg-[#07090d]">
        <DockScene />

        <NeonFrame
          color="amber"
          active
          bracket={8}
          tabs={{ bottomLeft: "Forklift 92%" }}
          className="absolute left-[53%] top-[33%] h-[56%] w-[30%] border-transparent bg-transparent p-0"
          aria-label="Detected: forklift, 92% confidence"
        />
      </div>
      <figcaption className="sr-only">Camera 3, loading dock, recording. One forklift detected.</figcaption>
    </NeonFrame>
  );
}
```

## API reference

### NeonFrame

Also accepts the native attributes of the element it renders. Corners are square so the brackets meet them; padding comes from className.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `as` | `"div" \| "section" \| "article" \| "aside" \| "figure"` | `"div"` | Element to render. |
| `label` | `ReactNode` | — | The frame's name, on a lit tab at the top-left edge. On anything but a div it also labels the element. |
| `tabs` | `{ topRight?: ReactNode; bottomLeft?: ReactNode; bottomRight?: ReactNode }` | — | Quieter tabs on the other corners, for a channel, a status or a reading. |
| `scanline` | `boolean` | `false` | A slow scan line sweeps down over the content. |
| `bracket` | `number` | `18` | Length of each bracket arm in pixels. |
| `active` | `boolean` | — | Leave undefined for a frame that is always lit. true locks on (brackets tighten and brighten); false parks it in standby until hovered or focused. |
| `animate` | `boolean` | `true` | Draw the brackets in from the corners when the frame scrolls into view. |
| `color` | `NeonColor` | `inherited` | Bracket and label color. Falls back to --neon-accent, then cyan. |

## Accessibility

- Brackets, the scan line and the corner light are aria-hidden; tabs are plain text, read in order around the content.
- Rendered as a section, article, aside or figure, the frame is named by its label through aria-labelledby, unless you pass aria-label or aria-labelledby.
- Focus inside the frame brightens the brackets, the same as hover, and lights a standby frame.
- With reduced motion the brackets and tabs appear at once and there is no scan line.

## Source

`components/ui/neon-frame.tsx`

```tsx
"use client";

import * as React from "react";

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

const EASE = "cubic-bezier(0.23,1,0.32,1)";

const css = `
@keyframes nk-neon-frame-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
@keyframes nk-neon-frame-ignite { 0% { opacity: 1; } 20% { opacity: 0.25; } 34% { opacity: 1; } 52% { opacity: 0.55; } 70%, 100% { opacity: 1; } }
@keyframes nk-neon-frame-scan { 0% { transform: translateY(-100%); opacity: 0; } 6% { opacity: 1; } 64% { transform: translateY(0); opacity: 1; } 72%, 100% { transform: translateY(0); opacity: 0; } }
.nk-neon-frame-corner {
  transform: scale(var(--nk-sx), var(--nk-sy));
  filter: drop-shadow(0 0 1.5px rgb(var(--neon) / calc(0.9 * var(--neon-glow, 1)))) drop-shadow(0 0 7px rgb(var(--neon) / calc(0.45 * var(--neon-glow, 1))));
  transition: transform 260ms ${EASE}, filter 200ms ease-out;
}
.nk-neon-frame:is(:hover, :focus-within) > .nk-neon-frame-corner,
.nk-neon-frame[data-active="true"] > .nk-neon-frame-corner {
  filter: drop-shadow(0 0 2px rgb(var(--neon) / var(--neon-glow, 1))) drop-shadow(0 0 12px rgb(var(--neon) / calc(0.75 * var(--neon-glow, 1))));
}
.nk-neon-frame[data-active="true"] > .nk-neon-frame-corner {
  transform: translate(calc(var(--nk-sx) * 3px), calc(var(--nk-sy) * 3px)) scale(var(--nk-sx), var(--nk-sy));
}
.nk-neon-frame-light { transition: opacity 200ms ease-out; }
.nk-neon-frame[data-active="false"] > .nk-neon-frame-corner { filter: none; }
.nk-neon-frame[data-active="false"] .nk-neon-frame-light,
.nk-neon-frame[data-active="false"] .nk-neon-frame-fade { opacity: 0; }
.nk-neon-frame[data-active="false"]:is(:hover, :focus-within) > .nk-neon-frame-corner { filter: drop-shadow(0 0 6px rgb(var(--neon) / calc(0.4 * var(--neon-glow, 1)))); }
.nk-neon-frame[data-active="false"]:is(:hover, :focus-within) .nk-neon-frame-light { opacity: 0.7; }
.nk-neon-frame-arm { stroke-dasharray: 1; stroke-dashoffset: 0; }
.nk-neon-frame[data-state="armed"] .nk-neon-frame-arm { stroke-dashoffset: 1; }
.nk-neon-frame[data-state="armed"] .nk-neon-frame-fade { opacity: 0; }
.nk-neon-frame[data-state="armed"] .nk-neon-frame-tab { opacity: 0; transform: translateY(var(--nk-ty)) translateY(var(--nk-from)); }
.nk-neon-frame[data-state="drawing"] .nk-neon-frame-arm { animation: nk-neon-frame-draw 420ms ${EASE} var(--nk-delay) both; }
.nk-neon-frame[data-state="drawing"] .nk-neon-frame-light > g { animation: nk-neon-frame-ignite 380ms linear calc(var(--nk-delay) + 380ms); }
.nk-neon-frame-fade { transition: opacity 400ms ease-out 260ms; }
.nk-neon-frame-tab { transform: translateY(var(--nk-ty)); transition: opacity 220ms ease-out 420ms, transform 260ms ${EASE} 420ms; }
.nk-neon-frame-scan { animation: nk-neon-frame-scan 5.5s cubic-bezier(0.45,0,0.55,1) infinite; }
@media (prefers-reduced-motion: reduce) {
  .nk-neon-frame[data-state] .nk-neon-frame-arm { animation: none; stroke-dashoffset: 0; }
  .nk-neon-frame[data-state] .nk-neon-frame-light > g { animation: none; }
  .nk-neon-frame[data-state] .nk-neon-frame-fade { opacity: 1; transition: none; }
  .nk-neon-frame[data-state] .nk-neon-frame-tab { opacity: 1; transform: translateY(var(--nk-ty)); transition: none; }
  .nk-neon-frame-corner { transition: filter 200ms ease-out; }
  .nk-neon-frame-scan { display: none; }
}
`;

type Corner = "topLeft" | "topRight" | "bottomRight" | "bottomLeft";

const corners: { corner: Corner; position: string; sx: number; sy: number; delay: number }[] = [
  { corner: "topLeft", position: "-left-px -top-px", sx: 1, sy: 1, delay: 0 },
  { corner: "topRight", position: "-right-px -top-px", sx: -1, sy: 1, delay: 90 },
  { corner: "bottomRight", position: "-bottom-px -right-px", sx: -1, sy: -1, delay: 180 },
  { corner: "bottomLeft", position: "-bottom-px -left-px", sx: 1, sy: -1, delay: 270 },
];

export interface NeonFrameTabs {
  topRight?: React.ReactNode;
  bottomLeft?: React.ReactNode;
  bottomRight?: React.ReactNode;
}

export interface NeonFrameProps
  extends Omit<React.HTMLAttributes<HTMLElement>, "color"> {
  as?: "div" | "section" | "article" | "aside" | "figure";
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** The frame's name, on a lit tab at the top-left edge. Labels a section, article, aside or figure. */
  label?: React.ReactNode;
  /** Quieter tabs on the other corners, for a channel, a status or a reading. */
  tabs?: NeonFrameTabs;
  /** A slow scan line sweeps down the frame. */
  scanline?: boolean;
  /** Length of each bracket arm in pixels. */
  bracket?: number;
  /**
   * For frames people pick between. true locks on: the brackets tighten in and
   * brighten. false parks the frame in standby: unlit glass brackets that light
   * up on hover and focus. Leave it undefined for a frame that is always lit.
   */
  active?: boolean;
  /** Draw the brackets in from the corners when the frame scrolls into view. */
  animate?: boolean;
}

const NeonFrame = React.forwardRef<HTMLElement, NeonFrameProps>(
  (
    {
      className,
      style,
      color,
      as: Comp = "div",
      label,
      tabs,
      scanline = false,
      bracket = 18,
      active,
      animate = true,
      children,
      ...props
    },
    ref,
  ) => {
    const labelId = React.useId();
    const rootRef = React.useRef<HTMLElement | null>(null);
    const setRefs = React.useCallback(
      (node: HTMLElement | null) => {
        rootRef.current = node;
        if (typeof ref === "function") ref(node);
        else if (ref) ref.current = node;
      },
      [ref],
    );

    const [phase, setPhase] = React.useState<"armed" | "drawing" | "lit">(animate ? "armed" : "lit");

    React.useEffect(() => {
      const node = rootRef.current;
      if (!animate || !node) return;
      let timeout: ReturnType<typeof setTimeout> | undefined;
      const start = () => {
        setPhase("drawing");
        timeout = setTimeout(() => setPhase("lit"), 1100);
      };
      if (typeof IntersectionObserver === "undefined") {
        const frame = requestAnimationFrame(start);
        return () => {
          cancelAnimationFrame(frame);
          clearTimeout(timeout);
        };
      }
      const observer = new IntersectionObserver(
        (entries) => {
          if (entries.some((entry) => entry.isIntersecting)) {
            observer.disconnect();
            start();
          }
        },
        { threshold: 0.3 },
      );
      observer.observe(node);
      return () => {
        observer.disconnect();
        clearTimeout(timeout);
      };
    }, [animate]);

    const hasLabel = label != null && label !== false;
    const landmark = Comp !== "div";
    const labelled =
      landmark && hasLabel && props["aria-label"] === undefined && props["aria-labelledby"] === undefined;

    const tab = (
      content: React.ReactNode,
      side: "top" | "bottom",
      edge: "left" | "right",
      primary = false,
    ) => (
      <span
        id={primary ? labelId : undefined}
        className={cn(
          "nk-neon-frame-tab absolute z-10 inline-flex h-5 items-center gap-1.5 whitespace-nowrap border bg-[#07080c] px-2",
          // Set in the body face and left in the case it's given; wrap readings in font-mono yourself.
          "text-[11px] font-medium leading-none tracking-[0.01em]",
          side === "top" ? "top-0" : "bottom-0",
          edge === "left" ? "left-[calc(var(--nk-arm)+10px)]" : "right-[calc(var(--nk-arm)+10px)]",
          primary
            ? "border-[rgb(var(--neon)/0.4)] text-[rgb(var(--neon))] [text-shadow:0_0_8px_rgb(var(--neon)/CALC(0.55*var(--neon-glow,1)))]"
            : "border-white/10 text-white/55",
        )}
        style={
          {
            "--nk-ty": side === "top" ? "-50%" : "50%",
            "--nk-from": side === "top" ? "4px" : "-4px",
          } as React.CSSProperties
        }
      >
        {primary && (
          <span aria-hidden className="size-1 shrink-0 bg-[rgb(var(--neon))] shadow-[0_0_6px_rgb(var(--neon)/var(--neon-glow,1))]" />
        )}
        {content}
      </span>
    );

    return (
      <Comp
        ref={setRefs as React.Ref<HTMLDivElement>}
        data-state={phase}
        data-active={active === undefined ? undefined : String(active)}
        aria-labelledby={labelled ? labelId : undefined}
        style={neonVars(color, { "--nk-arm": `${bracket}px`, ...style } as React.CSSProperties)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "nk-neon-frame relative isolate border border-white/[0.08] bg-white/[0.02] p-5",
          className,
        )}
        {...props}
      >
        <style>{css}</style>

        {/* The brackets' light on the panel */}
        <span
          aria-hidden
          className="nk-neon-frame-fade pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_0_0,rgb(var(--neon)/CALC(0.1*var(--neon-glow,1))),transparent_72px),radial-gradient(circle_at_100%_0,rgb(var(--neon)/CALC(0.1*var(--neon-glow,1))),transparent_72px),radial-gradient(circle_at_100%_100%,rgb(var(--neon)/CALC(0.1*var(--neon-glow,1))),transparent_72px),radial-gradient(circle_at_0_100%,rgb(var(--neon)/CALC(0.1*var(--neon-glow,1))),transparent_72px)]"
        />

        {hasLabel && tab(label, "top", "left", true)}
        {tabs?.topRight != null && tab(tabs.topRight, "top", "right")}

        {children}

        {tabs?.bottomLeft != null && tab(tabs.bottomLeft, "bottom", "left")}
        {tabs?.bottomRight != null && tab(tabs.bottomRight, "bottom", "right")}

        {scanline && (
          <span aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
            <span className="nk-neon-frame-scan absolute inset-0">
              <span className="absolute inset-x-0 bottom-0 h-16 bg-[linear-gradient(to_bottom,transparent,rgb(var(--neon)/0.07))]" />
              <span className="absolute inset-x-0 bottom-0 h-px bg-[rgb(var(--neon)/0.5)] shadow-[0_0_8px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1)))]" />
            </span>
          </span>
        )}

        {corners.map(({ corner, position, sx, sy, delay }) => (
          <svg
            key={corner}
            aria-hidden
            data-corner={corner}
            viewBox={`0 0 ${bracket + 2} ${bracket + 2}`}
            className={cn("nk-neon-frame-corner pointer-events-none absolute overflow-visible", position)}
            style={
              {
                width: bracket + 2,
                height: bracket + 2,
                "--nk-sx": sx,
                "--nk-sy": sy,
                "--nk-delay": `${delay}ms`,
              } as React.CSSProperties
            }
          >
            {/* The glass, there before the light runs through it */}
            <g>
              {[`M1 1V${bracket + 1}`, `M1 1H${bracket + 1}`].map((d) => (
                <path key={d} d={d} fill="none" strokeWidth={2} strokeLinecap="round" style={{ stroke: "rgb(var(--neon) / 0.2)" }} />
              ))}
            </g>
            <g className="nk-neon-frame-light">
              <g>
                {[`M1 1V${bracket + 1}`, `M1 1H${bracket + 1}`].map((d) => (
                  <React.Fragment key={d}>
                    <path
                      d={d}
                      pathLength={1}
                      className="nk-neon-frame-arm"
                      fill="none"
                      strokeWidth={2}
                      strokeLinecap="round"
                      style={{ stroke: "rgb(var(--neon))" }}
                    />
                    <path
                      d={d}
                      pathLength={1}
                      className="nk-neon-frame-arm"
                      fill="none"
                      strokeWidth={0.9}
                      strokeLinecap="round"
                      style={{ stroke: "color-mix(in srgb, rgb(var(--neon)) 30%, white)" }}
                    />
                  </React.Fragment>
                ))}
              </g>
            </g>
          </svg>
        ))}
      </Comp>
    );
  },
);
NeonFrame.displayName = "NeonFrame";

export { NeonFrame };
```
