# Neon Icon

> Status icons whose bars light in sequence, and any NeonKit icon drawn as a lit tube.

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

## Installation

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

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

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

## Usage

```tsx
import { NeonIcon } from "@/components/ui/neon-icon"
import { Mic } from "@/components/ui/icons"

<NeonIcon icon={Mic} value={live ? 1 : 0} />
<NeonIcon icon="signal" value={0.75} />
<NeonIcon icon="battery" value={0.4} animated />
```

## Examples

### Studio panel

Any icon from the set, lit. Each key strikes on with a flicker, its halo warming a beat after the tube, and goes back to unlit glass when switched off. Mic and Stream together light On air.

```tsx
"use client";

import * as React from "react";
import { NeonIcon } from "@/components/ui/neon-icon";
import { Headphones, Lamp, Lock, Mic, Radio, Rss, Video, type Icon } from "@/components/ui/icons";

type Channel = { id: string; icon: Icon; name: string; on: string; off: string };

const channels: Channel[] = [
  { id: "mic", icon: Mic, name: "Mic 1", on: "Live", off: "Muted" },
  { id: "cam", icon: Video, name: "Cam A", on: "Recording", off: "Standby" },
  { id: "stream", icon: Rss, name: "Stream", on: "6 Mb/s", off: "Offline" },
  { id: "talkback", icon: Headphones, name: "Talkback", on: "Open", off: "Closed" },
  { id: "lights", icon: Lamp, name: "House lights", on: "80%", off: "Off" },
  { id: "door", icon: Lock, name: "Door", on: "Locked", off: "Open" },
];

// On air is a status, so it keeps its red whatever accent the rest of the panel takes.
const onAirColor = "red";

// Switched on one after another when the panel powers up.
const startup = ["door", "lights", "mic", "cam", "stream"];

function clock(seconds: number) {
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = seconds % 60;
  return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":");
}

export default function NeonIconDemo() {
  const [on, setOn] = React.useState<Record<string, boolean>>({});
  const [elapsed, setElapsed] = React.useState(2537);
  const onAir = Boolean(on.mic && on.stream);

  React.useEffect(() => {
    const timers = startup.map((id, i) =>
      setTimeout(() => setOn((state) => ({ ...state, [id]: true })), 350 + i * 220),
    );
    return () => timers.forEach(clearTimeout);
  }, []);

  React.useEffect(() => {
    if (!onAir) return;
    const interval = setInterval(() => setElapsed((value) => value + 1), 1000);
    return () => clearInterval(interval);
  }, [onAir]);

  return (
    <div className="w-full max-w-[44rem] rounded-[18px] border border-white/[0.07] bg-[linear-gradient(180deg,#101218,#08090d)] shadow-[inset_0_1px_0_rgb(255_255_255/0.05),0_40px_80px_-40px_rgb(0_0_0/0.95)]">
      <div className="flex items-center justify-between gap-4 border-b border-white/[0.06] px-5 py-4 sm:px-7">
        <div>
          <p className="text-[15px] font-medium text-white/90">Studio B</p>
          <p className="text-[13px] text-white/55">Late show, Thursday</p>
        </div>
        <div className="flex items-center gap-3" aria-live="polite">
          <span className="text-right">
            <span className={`block text-[13px] ${onAir ? "text-[rgb(255_77_94)]" : "text-white/55"}`}>
              {onAir ? "On air" : "Off air"}
            </span>
            <span className="block font-mono text-xs tabular-nums text-white/70">{clock(elapsed)}</span>
          </span>
          <NeonIcon icon={Radio} value={onAir ? 1 : 0} color={onAirColor} size={34} label={onAir ? "On air" : "Off air"} />
        </div>
      </div>

      <ul className="grid grid-cols-3 sm:grid-cols-6">
        {channels.map((channel, i) => {
          const lit = Boolean(on[channel.id]);
          return (
            <li
              key={channel.id}
              className={`border-white/[0.06] ${i % 3 !== 2 ? "border-r" : ""} ${i < 3 ? "border-b sm:border-b-0" : ""} sm:border-r sm:last:border-r-0`}
            >
              <button
                type="button"
                aria-pressed={lit}
                onClick={() => setOn((state) => ({ ...state, [channel.id]: !state[channel.id] }))}
                className="group flex w-full cursor-pointer flex-col items-center gap-4 px-2 pb-5 pt-7 outline-none transition-colors duration-150 hover:bg-white/[0.025] focus-visible:bg-white/[0.03] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[rgb(var(--neon-accent,34_211_238)/0.6)] active:bg-white/[0.04] sm:pt-8"
              >
                <NeonIcon icon={channel.icon} value={lit ? 1 : 0} size={40} aria-hidden />
                <span className="grid gap-0.5 text-center">
                  <span className="text-[13px] text-white/90">{channel.name}</span>
                  <span className={`text-[13px] tabular-nums ${lit ? "text-white/70" : "text-white/50"}`}>
                    {lit ? channel.on : channel.off}
                  </span>
                </span>
              </button>
            </li>
          );
        })}
      </ul>
    </div>
  );
}
```

### Levels

The built-in status icons follow value. Drag the link slider: bars and arcs light one after another on the way up and go out from the top on the way down. The others run their idle loops.

```tsx
"use client";

import * as React from "react";
import { NeonIcon, type NeonIconName } from "@/components/ui/neon-icon";
import { Slider } from "@/components/ui/slider";

type Readout = { icon: NeonIconName; name: string; value: number; animated?: boolean; reading: string };

export default function NeonIconLevels() {
  const [link, setLink] = React.useState(72);
  const quality = link / 100;
  const dbm = `${Math.round(-112 + link * 0.62)}`.replace("-", "−");
  const arcs = link === 0 ? 0 : Math.max(1, Math.round(quality * 3));

  const readouts: Readout[] = [
    { icon: "signal", name: "Signal", value: quality, reading: link === 0 ? "No link" : `${dbm} dBm` },
    { icon: "wifi", name: "Mesh", value: quality, reading: arcs === 0 ? "Off" : `${arcs} of 3` },
    { icon: "battery", name: "Cell", value: 0.64, animated: true, reading: "64%" },
    { icon: "bolt", name: "Supply", value: 1, animated: true, reading: "48 W" },
    { icon: "power", name: "Radio", value: 1, reading: "On" },
    { icon: "heart", name: "Watchdog", value: 1, animated: true, reading: "1.1 s" },
    { icon: "bell", name: "Alerts", value: 1, animated: true, reading: "3 new" },
  ];

  return (
    <div className="w-full max-w-2xl rounded-[18px] border border-white/[0.07] bg-[linear-gradient(180deg,#11131a,#0a0b10)] px-5 pb-5 pt-5 shadow-[inset_0_1px_0_rgb(255_255_255/0.05),0_30px_60px_-30px_rgb(0_0_0/0.9)] sm:px-8 sm:pb-7 sm:pt-6">
      <div className="flex items-baseline justify-between gap-4">
        <p className="text-sm font-medium text-white/90">Relay KX-7</p>
        <p className="text-[13px] text-white/55">Field unit, channel 4</p>
      </div>

      <ul className="mt-7 grid grid-cols-4 gap-x-2 gap-y-7 sm:grid-cols-7">
        {readouts.map((readout) => (
          <li key={readout.name} className="flex flex-col items-center gap-3 text-center">
            <NeonIcon icon={readout.icon} value={readout.value} animated={readout.animated} size={36} />
            <div className="grid gap-0.5">
              <span className="text-[13px] text-white/55">{readout.name}</span>
              <span className="text-[13px] tabular-nums text-white/90">{readout.reading}</span>
            </div>
          </li>
        ))}
      </ul>

      <div className="mt-8 flex items-center gap-4 border-t border-white/[0.06] pt-5">
        <span className="text-[13px] text-white/55">Link</span>
        <Slider
          value={[link]}
          onValueChange={([value]) => setLink(value)}
          aria-label="Link quality"
          formatValue={(value) => `${value}%`}
          size="sm"
        />
        <span className="w-[4.5rem] shrink-0 text-right font-mono text-xs tabular-nums text-white/70">
          {link === 0 ? "—" : `${dbm} dBm`}
        </span>
      </div>
    </div>
  );
}
```

### Toolbar state

Active marks and the current alignment are lit; the rest stay as glass. 18px icons with the set's 1.75 tube.

```tsx
"use client";

import * as React from "react";
import { NeonIcon } from "@/components/ui/neon-icon";
import { AlignCenter, AlignLeft, AlignRight, Bold, Code, Italic, Strikethrough, Underline, type Icon } from "@/components/ui/icons";

type Mark = "bold" | "italic" | "underline" | "strike" | "code";
type Align = "left" | "center" | "right";

const marks: { id: Mark; icon: Icon; label: string }[] = [
  { id: "bold", icon: Bold, label: "Bold" },
  { id: "italic", icon: Italic, label: "Italic" },
  { id: "underline", icon: Underline, label: "Underline" },
  { id: "strike", icon: Strikethrough, label: "Strikethrough" },
  { id: "code", icon: Code, label: "Code" },
];

const aligns: { id: Align; icon: Icon; label: string }[] = [
  { id: "left", icon: AlignLeft, label: "Align left" },
  { id: "center", icon: AlignCenter, label: "Align center" },
  { id: "right", icon: AlignRight, label: "Align right" },
];

const key =
  "grid size-8 cursor-pointer place-items-center sm:size-9 rounded-lg outline-none transition-colors duration-150 hover:bg-white/[0.05] focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon-accent,34_211_238)/0.6)] active:scale-[0.97]";

export default function NeonIconToolbar() {
  const [active, setActive] = React.useState<Set<Mark>>(() => new Set(["bold"]));
  const [align, setAlign] = React.useState<Align>("left");
  const [focused, setFocused] = React.useState(0);

  // Arrow keys move along the toolbar, as in the WAI-ARIA toolbar pattern.
  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
    const buttons = [...event.currentTarget.querySelectorAll("button")];
    const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
    const next =
      event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index + (event.key === "ArrowRight" ? 1 : -1) + buttons.length) % buttons.length;
    buttons[next]?.focus();
    event.preventDefault();
  };

  const toggle = (mark: Mark) =>
    setActive((current) => {
      const next = new Set(current);
      if (next.has(mark)) next.delete(mark);
      else next.add(mark);
      return next;
    });

  return (
    <div className="w-full max-w-md overflow-hidden rounded-xl border border-white/10 bg-[#0a0b11]">
      <div
        role="toolbar"
        aria-label="Formatting"
        onKeyDown={onKeyDown}
        className="flex flex-wrap items-center gap-0.5 border-b border-white/[0.07] px-2 py-1.5"
      >
        {marks.map((mark, i) => (
          <button
            key={mark.id}
            type="button"
            aria-label={mark.label}
            aria-pressed={active.has(mark.id)}
            tabIndex={focused === i ? 0 : -1}
            onFocus={() => setFocused(i)}
            onClick={() => toggle(mark.id)}
            className={key}
          >
            <NeonIcon icon={mark.icon} value={active.has(mark.id) ? 1 : 0} size={18} aria-hidden />
          </button>
        ))}
        <span aria-hidden className="mx-1.5 h-5 w-px bg-white/10" />
        {aligns.map((option, i) => (
          <button
            key={option.id}
            type="button"
            aria-label={option.label}
            aria-pressed={align === option.id}
            tabIndex={focused === marks.length + i ? 0 : -1}
            onFocus={() => setFocused(marks.length + i)}
            onClick={() => setAlign(option.id)}
            className={key}
          >
            <NeonIcon icon={option.icon} value={align === option.id ? 1 : 0} size={18} aria-hidden />
          </button>
        ))}
      </div>
      <p
        className="px-4 py-4 text-[14.5px] leading-relaxed text-white/80"
        style={{ textAlign: align }}
      >
        <span
          className={[
            active.has("bold") && "font-semibold text-white/95",
            active.has("italic") && "italic",
            active.has("underline") && "underline decoration-white/40 underline-offset-4",
            active.has("strike") && "line-through decoration-white/50",
            active.has("code") && "rounded-md bg-white/[0.06] px-1 font-mono text-[13px]",
          ]
            .filter(Boolean)
            .join(" ")}
        >
          Webhooks now retry for 72 hours.
        </span>{" "}
        Failed deliveries back off from 30 seconds to 6 hours and show up under Settings, Events.
      </p>
    </div>
  );
}
```

### Status bar

Animated signal means searching. A battery at 20% or less turns red unless you pass a color.

```tsx
import { NeonIcon } from "@/components/ui/neon-icon";

const states = [
  { note: "Connected", signal: 0.75, signalSearching: false, wifi: 1, battery: 0.82, charging: false, time: "9:41" },
  { note: "Roaming, plugged in", signal: 0.25, signalSearching: false, wifi: 0, battery: 0.47, charging: true, time: "23:08" },
  { note: "No service, battery low", signal: 0, signalSearching: true, wifi: 0, battery: 0.12, charging: false, time: "6:15" },
];

export default function NeonIconStatusBar() {
  return (
    <div className="grid w-full max-w-sm gap-4">
      {states.map((state) => (
        <div key={state.note} className="grid gap-1.5">
          <div className="flex h-10 items-center justify-between rounded-full border border-white/10 bg-[#0a0b11] px-5">
            <span className="text-sm font-semibold tabular-nums text-white/90">{state.time}</span>
            <span className="flex items-center gap-1.5">
              <NeonIcon icon="signal" size="sm" value={state.signal} animated={state.signalSearching} />
              {state.wifi > 0 && <NeonIcon icon="wifi" size="sm" value={state.wifi} />}
              <NeonIcon icon="battery" size={22} value={state.battery} animated={state.charging} />
            </span>
          </div>
          <p className="px-5 text-xs text-white/55">{state.note}</p>
        </div>
      ))}
    </div>
  );
}
```

### Battery states

Four cells. Charging keeps the charged cells lit and runs the rest in sequence.

```tsx
import { NeonIcon } from "@/components/ui/neon-icon";

const cells = [
  { label: "Full", value: 1 },
  { label: "Charging", value: 0.58, animated: true },
  { label: "Half", value: 0.5 },
  { label: "Low", value: 0.14 },
  { label: "Empty", value: 0 },
];

export default function NeonIconBattery() {
  return (
    <div className="flex flex-wrap justify-center gap-8">
      {cells.map((cell) => (
        <div key={cell.label} className="flex flex-col items-center gap-2">
          <NeonIcon icon="battery" size={40} value={cell.value} animated={cell.animated} />
          <span className="text-xs text-white/55">
            {cell.label} <span className="tabular-nums text-white/35">{Math.round(cell.value * 100)}%</span>
          </span>
        </div>
      ))}
    </div>
  );
}
```

### Notification bell

The bell swings and its sound arcs flash while animated. Here it rings for one cycle per new notification.

```tsx
"use client";

import * as React from "react";
import { NeonIcon } from "@/components/ui/neon-icon";
import { Button } from "@/components/ui/button";

const incoming = [
  "Deploy 4f2c1a finished on fra1",
  "Mara Okafor mentioned you in #billing",
  "Certificate for halcyon.dev renews in 7 days",
  "Weekly usage report is ready",
];

export default function NeonIconNotifications() {
  const [count, setCount] = React.useState(2);
  const [ringing, setRinging] = React.useState(false);

  React.useEffect(() => {
    if (!ringing) return;
    const timeout = setTimeout(() => setRinging(false), 2400);
    return () => clearTimeout(timeout);
  }, [ringing, count]);

  const latest = incoming[(count - 1) % incoming.length];

  return (
    <div className="flex w-full max-w-sm flex-col items-center gap-5">
      <div className="flex w-full items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] p-3 pr-4">
        <button
          type="button"
          aria-label={count ? `${count} unread notifications` : "Notifications"}
          onClick={() => setCount(0)}
          className="relative grid size-10 shrink-0 place-items-center rounded-lg outline-none transition-colors hover:bg-white/[0.05] focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon-accent,34_211_238)/0.6)]"
        >
          <NeonIcon icon="bell" value={count > 0 ? 1 : 0} animated={ringing} aria-hidden />
          {count > 0 && (
            <span className="absolute right-1 top-1 grid h-4 min-w-4 place-items-center rounded-full bg-[rgb(255_77_94)] px-1 text-[10px] font-semibold tabular-nums text-white shadow-[0_0_8px_rgb(255_77_94/0.7)]">
              {count}
            </span>
          )}
        </button>
        <div className="min-w-0">
          <p className="text-sm text-white/90">{count ? `${count} unread` : "All caught up"}</p>
          <p className="truncate text-xs text-white/55">{count ? latest : "Nothing new since 9:12"}</p>
        </div>
      </div>
      <Button
        variant="ghost"
        size="sm"
        onClick={() => {
          setCount((value) => value + 1);
          setRinging(true);
        }}
      >
        Simulate a notification
      </Button>
    </div>
  );
}
```

### Sizes and buttons

sm, md and lg are 16, 20 and 24px, or pass any pixel size. Inside a Button use sm, the size Button gives its icons.

```tsx
import { NeonIcon } from "@/components/ui/neon-icon";
import { Button } from "@/components/ui/button";

export default function NeonIconSizes() {
  return (
    <div className="grid justify-items-center gap-8">
      <div className="flex items-end gap-6">
        {(["sm", "md", "lg"] as const).map((size) => (
          <div key={size} className="flex flex-col items-center gap-2">
            <NeonIcon icon="wifi" size={size} value={0.7} />
            <span className="font-mono text-[11px] text-white/55">{size}</span>
          </div>
        ))}
        <div className="flex flex-col items-center gap-2">
          <NeonIcon icon="wifi" size={48} value={0.7} />
          <span className="font-mono text-[11px] text-white/55">48</span>
        </div>
      </div>
      <div className="flex flex-wrap items-center justify-center gap-3">
        <Button size="sm">
          <NeonIcon icon="power" size="sm" aria-hidden /> Restart
        </Button>
        <Button size="md">
          <NeonIcon icon="signal" size="sm" value={0.5} aria-hidden /> Run speed test
        </Button>
        <Button size="lg">
          <NeonIcon icon="bolt" size="sm" aria-hidden /> Boost
        </Button>
      </div>
    </div>
  );
}
```

## API reference

### NeonIcon

Also accepts native span attributes.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `icon` | `"signal" \| "wifi" \| "battery" \| "bolt" \| "power" \| "heart" \| "bell" \| Icon \| ReactElement` | — | A built-in status icon, or any icon from @/components/ui/icons drawn as a lit tube. Pass the component (icon={Mic}) in client code, or the element (icon={<Mic />}) from a server component. |
| `value` | `number` | `1` | 0 to 1. Lights 4 signal bars, 3 Wi-Fi arcs or 4 battery cells; any value above 0 lights at least one. For bolt, power, heart, bell and icons from the set, above 0 is on and 0 is unlit glass. |
| `animated` | `boolean` | `false` | The idle loop: signal and Wi-Fi search, battery charges, bolt surges, power breathes on standby, heart beats, bell rings. An icon from the set surges like a tired tube. |
| `ignite` | `boolean` | `false` | Strike on with the power-on flicker when the icon mounts lit. Going from 0 to on always plays it. |
| `strokeWidth` | `number` | `1.75` | Tube thickness for icons from the set, in 24px grid units. |
| `size` | `"sm" \| "md" \| "lg" \| number` | `"md"` | 16, 20 or 24px, or any size in pixels. Strokes and glow scale with it. |
| `label` | `string` | `describes the state` | Accessible name, e.g. "Signal: 3 of 4 bars", "Battery 64%, charging" or "Mic, off" by default. |
| `color` | `NeonColor` | `inherited` | Tube color. Falls back to --neon-accent, then cyan; a low battery falls back to red. |

## Accessibility

- An icon from the set is layered four times: unlit glass, a wide soft halo, the tube, and a hot core that stays at least 0.6px wide. The halo is tight enough that the blockout gaps where one tube passes behind another stay dark.
- The icon is role="img" with a label that describes its state, so a level change is never color or shape alone.
- Pass aria-hidden when a visible label or a button's name already says the same thing; the role and label are dropped.
- Every loop and the ignition flicker stop under reduced motion. The icon still shows its level, and a ringing bell keeps its sound arcs lit.

## Source

`components/ui/neon-icon.tsx`

```tsx
"use client";

import * as React from "react";

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

export type NeonIconName = "signal" | "wifi" | "battery" | "bolt" | "power" | "heart" | "bell";

// level: one step of the meter · on: lit while value > 0 · always: the casing · ring: lit while animated
type Role = "level" | "on" | "always" | "ring";
type Part = { d: string; role: Role; width: number; cap?: "round" | "butt" };

// Drawn as single strokes on a 24px grid, so every part can be a tube.
const icons: Record<NeonIconName, Part[]> = {
  signal: [
    { d: "M4.75 19.5v-2.75", role: "level", width: 2.5 },
    { d: "M9.5 19.5v-6.25", role: "level", width: 2.5 },
    { d: "M14.25 19.5v-9.75", role: "level", width: 2.5 },
    { d: "M19 19.5V6.25", role: "level", width: 2.5 },
  ],
  wifi: [
    { d: "M12 19.25h.01", role: "on", width: 3 },
    { d: "M8.75 16a4.6 4.6 0 0 1 6.5 0", role: "level", width: 2.1 },
    { d: "M5.71 12.96a8.9 8.9 0 0 1 12.58 0", role: "level", width: 2.1 },
    { d: "M2.67 9.92a13.2 13.2 0 0 1 18.66 0", role: "level", width: 2.1 },
  ],
  battery: [
    { d: "M4.5 6.75h13a2.25 2.25 0 0 1 2.25 2.25v6a2.25 2.25 0 0 1-2.25 2.25h-13A2.25 2.25 0 0 1 2.25 15V9A2.25 2.25 0 0 1 4.5 6.75Z", role: "always", width: 1.5 },
    { d: "M22 10.25v3.5", role: "always", width: 1.75 },
    { d: "M5.45 9.4v5.2", role: "level", width: 2.4, cap: "butt" },
    { d: "M8.9 9.4v5.2", role: "level", width: 2.4, cap: "butt" },
    { d: "M12.35 9.4v5.2", role: "level", width: 2.4, cap: "butt" },
    { d: "M15.8 9.4v5.2", role: "level", width: 2.4, cap: "butt" },
  ],
  bolt: [{ d: "M13.5 2.75 5 13.25h6.25l-1 8 8.75-10.5h-6.25l.75-8Z", role: "on", width: 1.6 }],
  power: [
    { d: "M16.93 6.7A8 8 0 1 1 7.07 6.7", role: "on", width: 1.9 },
    { d: "M12 3.25v8", role: "on", width: 1.9 },
  ],
  heart: [
    { d: "M12 20.25 4.6 12.85a4.6 4.6 0 0 1 6.5-6.5l.9.9.9-.9a4.6 4.6 0 0 1 6.5 6.5Z", role: "on", width: 1.6 },
  ],
  bell: [
    { d: "M5.25 17.25h13.5l-1.5-2V10.5a5.25 5.25 0 0 0-10.5 0v4.75Z", role: "on", width: 1.6 },
    { d: "M10 20.25a2.1 2.1 0 0 0 4 0", role: "on", width: 1.6 },
    { d: "M12 3.25v2", role: "on", width: 1.6 },
    { d: "M19.75 4.5a8.6 8.6 0 0 1 2 3.75", role: "ring", width: 1.6 },
    { d: "M4.25 4.5a8.6 8.6 0 0 0-2 3.75", role: "ring", width: 1.6 },
  ],
};

/** Props NeonIcon passes to an icon component. Every icon in `@/components/ui/icons` (and lucide-react) takes them. */
export interface NeonIconGlyphProps {
  size?: number | string;
  strokeWidth?: number | string;
  className?: string;
  style?: React.CSSProperties;
  "aria-hidden"?: boolean | "true" | "false";
}

/** An icon component to light, e.g. `Mic` from `@/components/ui/icons`. */
export type NeonIconGlyph = React.ComponentType<NeonIconGlyphProps>;

const sizes = { sm: 16, md: 20, lg: 24 } as const;

const baseCss = `
@keyframes nk-neon-icon-ignite { 0% { opacity: 0; } 14% { opacity: 0.9; } 26% { opacity: 0.15; } 44% { opacity: 1; } 56% { opacity: 0.45; } 72%, 100% { opacity: 1; } }
@keyframes nk-neon-icon-surge { 0%, 84%, 100% { opacity: 1; } 86% { opacity: 0.3; } 88% { opacity: 1; } 91% { opacity: 0.5; } 93% { opacity: 1; } }
@keyframes nk-neon-icon-breathe { 0%, 100% { opacity: 1; } 50% { opacity: 0.28; } }
@keyframes nk-neon-icon-beat { 0% { transform: scale(1); } 14% { transform: scale(1.13); } 28% { transform: scale(0.98); } 42% { transform: scale(1.07); } 70%, 100% { transform: scale(1); } }
@keyframes nk-neon-icon-ring { 0% { transform: rotate(0deg); } 6% { transform: rotate(14deg); } 14% { transform: rotate(-12deg); } 22% { transform: rotate(9deg); } 30% { transform: rotate(-6deg); } 38% { transform: rotate(3deg); } 46%, 100% { transform: rotate(0deg); } }
@keyframes nk-neon-icon-strike { 0% { opacity: 0; } 5% { opacity: 0.85; } 9%, 19% { opacity: 0.04; } 23% { opacity: 1; } 27% { opacity: 0.22; } 31% { opacity: 0.95; } 43% { opacity: 0.5; } 49%, 100% { opacity: 1; } }
@keyframes nk-neon-icon-warm { 0%, 30% { opacity: 0; } 100% { opacity: 1; } }
@keyframes nk-neon-icon-wave { 0%, 3% { opacity: 0; } 6%, 34% { opacity: 1; } 48%, 100% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .nk-neon-icon-motion { animation: none !important; } }
`;

// Segments light one after another, hold together, then drop out together.
function seekKeyframes(count: number) {
  return Array.from({ length: count }, (_, i) => {
    const on = Math.round(8 + (i * 56) / count);
    return `@keyframes nk-neon-icon-seek-${count}-${i} { 0%, ${on}% { opacity: 0; } ${on + 2}%, 80% { opacity: 1; } 86%, 100% { opacity: 0; } }`;
  }).join("\n");
}

const clamp = (value: number) => Math.min(1, Math.max(0, value));

function defaultLabel(icon: NeonIconName, value: number, lit: number, levels: number, animated: boolean) {
  const on = value > 0;
  switch (icon) {
    case "signal":
      return animated ? "Searching for signal" : `Signal: ${lit} of ${levels} bars`;
    case "wifi":
      return animated ? "Searching for Wi-Fi" : on ? `Wi-Fi: ${lit} of ${levels} bars` : "Wi-Fi off";
    case "battery":
      return `Battery ${Math.round(clamp(value) * 100)}%${animated ? ", charging" : ""}`;
    case "bolt":
      return on ? "Powered" : "No power";
    case "power":
      return on ? (animated ? "Standby" : "Power on") : "Power off";
    case "heart":
      return on ? (animated ? "Heartbeat" : "Heart") : "Heart, off";
    case "bell":
      return animated ? "New notifications" : on ? "Notifications on" : "Notifications off";
  }
}

// "BellRing" -> "Bell ring". A server-rendered icon arrives as a plain <svg>, named by its nk-icon-<name> class.
function glyphName(icon: NeonIconGlyph | React.ReactElement) {
  let name = "";
  if (React.isValidElement(icon)) {
    const type = icon.type as string | NeonIconGlyph;
    if (typeof type === "string") {
      const match = /nk-icon-([a-z0-9-]+)/.exec(String((icon.props as { className?: string }).className ?? ""));
      name = match ? match[1].replace(/-/g, " ") : "";
    } else {
      name = type.displayName ?? type.name ?? "";
    }
  } else {
    name = icon.displayName ?? icon.name ?? "";
  }
  name = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([a-zA-Z])(\d)/g, "$1 $2").trim();
  return name ? name.charAt(0).toUpperCase() + name.slice(1).toLowerCase() : "Icon";
}

export interface NeonIconProps
  extends Omit<React.HTMLAttributes<HTMLSpanElement>, "color"> {
  /**
   * One of the built-in status icons, whose bars and cells follow `value`, or any icon from
   * `@/components/ui/icons` drawn as a lit tube: pass the component (`icon={Mic}`) or, from a
   * server component, the element (`icon={<Mic />}`).
   */
  icon: NeonIconName | NeonIconGlyph | React.ReactElement;
  /**
   * 0 to 1. Drives how many bars, arcs or cells are lit; for the single-part
   * icons anything above 0 is on. Rising levels light up in sequence.
   */
  value?: number;
  /**
   * The icon's idle loop: signal and wifi search, battery charges, bolt surges,
   * power breathes on standby, heart beats, bell rings.
   */
  animated?: boolean;
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** sm, md and lg match the icon sizes in Button (16, 20, 24px), or pass pixels. */
  size?: keyof typeof sizes | number;
  /** Accessible name. Defaults to a description of the state, like "Signal: 3 of 4 bars". */
  label?: string;
  /** Play the power-on flicker when the icon mounts lit. Turning on from 0 always plays it. */
  ignite?: boolean;
  /** Tube thickness for icon components, in 24px grid units. Default 1.75, the icon set's own. */
  strokeWidth?: number;
}

const NeonIcon = React.forwardRef<HTMLSpanElement, NeonIconProps>(
  (
    {
      className,
      style,
      icon,
      value = 1,
      animated = false,
      color,
      size = "md",
      label,
      ignite = false,
      strokeWidth = 1.75,
      "aria-hidden": ariaHidden,
      ...props
    },
    ref,
  ) => {
    const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
    const parts = typeof icon === "string" ? icons[icon] : [];
    const levels = parts.filter((part) => part.role === "level").length;
    const on = value > 0;
    const lit = !on || levels === 0 ? 0 : Math.max(1, Math.round(clamp(value) * levels));

    // Remember where the level came from, so the change can play in sequence.
    const [previous, setPrevious] = React.useState({ lit, on });
    // With `ignite`, mounting counts as a change from dark, so everything lit strikes on.
    const [change, setChange] = React.useState(() =>
      ignite && on ? { from: 0, wasOn: false, count: 1, mount: true } : { from: lit, wasOn: on, count: 0, mount: false },
    );
    if (previous.lit !== lit || previous.on !== on) {
      setChange({ from: previous.lit, wasOn: previous.on, count: change.count + 1, mount: false });
      setPrevious({ lit, on });
    }

    const lowBattery = icon === "battery" && !animated && clamp(value) <= 0.2;
    const px = typeof size === "number" ? size : sizes[size];
    const hidden = ariaHidden === true || ariaHidden === "true";

    if (typeof icon !== "string") {
      const element = React.isValidElement(icon) ? (icon as React.ReactElement<NeonIconGlyphProps & React.SVGProps<SVGSVGElement>>) : null;
      const Glyph = element ? null : (icon as NeonIconGlyph);
      const striking = change.count > 0 && on && !change.wasOn;
      // The hot core keeps at least 0.6 screen pixels, and runs less white when small so a 16px
      // icon reads as one bright line instead of an outline.
      const core = Math.max(strokeWidth * 0.42, (0.6 * 24) / px);
      const neonShare = px < 24 ? 62 : px < 40 ? 42 : 30;
      const layerClass = "pointer-events-none absolute inset-0 size-full overflow-visible";
      const layer = (width: number, layerStyle: React.CSSProperties) => {
        if (Glyph) {
          return <Glyph aria-hidden size={px} strokeWidth={width} className={layerClass} style={layerStyle} />;
        }
        const own = element!.props;
        // A host <svg> (rendered on the server) takes width and height; an icon component takes size.
        const sizing = typeof element!.type === "string" ? { width: px, height: px } : { size: px };
        return React.cloneElement(element!, {
          ...sizing,
          strokeWidth: width,
          "aria-hidden": true,
          className: cn(own.className, layerClass),
          style: { ...own.style, ...layerStyle },
        });
      };
      const loop = animated ? "nk-neon-icon-surge 2.8s linear infinite" : undefined;

      return (
        <span
          ref={ref}
          role={hidden ? undefined : "img"}
          aria-label={hidden ? undefined : (label ?? (on ? glyphName(icon) : `${glyphName(icon)}, off`))}
          aria-hidden={ariaHidden}
          data-icon={glyphName(icon).toLowerCase().replace(/ /g, "-")}
          data-level={on ? 1 : 0}
          data-animated={animated || undefined}
          style={neonVars(color, { width: px, height: px, ...style })}
          className={cn("[--neon:var(--neon-accent,34_211_238)]", "relative inline-flex shrink-0 align-middle", className)}
          {...props}
        >
          <style>{baseCss}</style>
          {/* Unlit glass: the drawing stays readable when the tube is off. */}
          {layer(strokeWidth, { stroke: "rgb(var(--neon) / 0.2)" })}
          <span
            data-part="tube"
            data-lit={on || undefined}
            className="absolute inset-0"
            style={{ opacity: on ? 1 : 0, transition: on ? "none" : "opacity 220ms ease-out" }}
          >
            <span
              key={striking ? change.count : 0}
              className={cn("absolute inset-0", (striking || loop) && "nk-neon-icon-motion")}
              style={{
                animation: striking
                  ? `nk-neon-icon-strike 900ms linear both${loop ? `, ${loop.replace("infinite", "900ms infinite")}` : ""}`
                  : loop,
              }}
            >
              {/* The halo blooms a beat after the tube strikes, like gas warming up. */}
              <span
                className={cn("absolute inset-0", striking && "nk-neon-icon-motion")}
                style={{ animation: striking ? "nk-neon-icon-warm 1.1s ease-out both" : undefined }}
              >
                {/* Light the tube throws on the surface behind it. */}
                <span
                  aria-hidden
                  className="pointer-events-none absolute -inset-1/2 rounded-full bg-[radial-gradient(closest-side,rgb(var(--neon)/CALC(0.17*var(--neon-glow,1))),rgb(var(--neon)/CALC(0.06*var(--neon-glow,1)))_50%,transparent)]"
                />
                {layer(strokeWidth * 2.8, { stroke: "rgb(var(--neon))", opacity: 0.36, filter: `blur(${px * 0.12}px)` })}
                {layer(strokeWidth * 1.35, { stroke: "rgb(var(--neon))", opacity: 0.7, filter: `blur(${px * 0.028}px)` })}
              </span>
              {layer(strokeWidth, { stroke: "rgb(var(--neon))" })}
              {layer(core, { stroke: `color-mix(in srgb, rgb(var(--neon)) ${neonShare}%, white)` })}
            </span>
          </span>
        </span>
      );
    }

    // Segments that run a loop while animated.
    const seeking = animated && (icon === "signal" || icon === "wifi");
    const charging = animated && icon === "battery";
    const chargeFrom = lit >= levels ? levels - 1 : lit;
    const loopCount = seeking ? levels : charging ? levels - chargeFrom : 0;

    const levelOf: number[] = [];
    for (const part of parts) {
      levelOf.push(part.role === "level" ? levelOf.filter((l) => l >= 0).length : -1);
    }

    return (
      <span
        ref={ref}
        role={hidden ? undefined : "img"}
        aria-label={hidden ? undefined : (label ?? defaultLabel(icon, value, lit, levels, animated))}
        aria-hidden={ariaHidden}
        data-icon={icon}
        data-level={levels ? lit : on ? 1 : 0}
        data-animated={animated || undefined}
        style={neonVars(color ?? (lowBattery ? "red" : undefined), { width: px, height: px, ...style })}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "relative inline-flex shrink-0 align-middle",
          className,
        )}
        {...props}
      >
        <style>{baseCss + (loopCount > 0 ? seekKeyframes(loopCount) : "")}</style>
        <svg viewBox="0 0 24 24" fill="none" className="size-full overflow-visible" aria-hidden>
          <defs>
            <filter id={`${uid}-glow`} filterUnits="userSpaceOnUse" x={-8} y={-8} width={40} height={40}>
              <feGaussianBlur stdDeviation={1.15} />
            </filter>
          </defs>
          <g
            className={cn(animated && (icon === "heart" || icon === "bell") && "nk-neon-icon-motion")}
            style={{
              transformBox: "view-box",
              transformOrigin: icon === "bell" ? "12px 3.5px" : "12px 13px",
              animation: animated
                ? icon === "heart"
                  ? "nk-neon-icon-beat 1.1s ease-out infinite"
                  : icon === "bell"
                    ? "nk-neon-icon-ring 2.4s ease-in-out infinite"
                    : undefined
                : undefined,
            }}
          >
            {parts.map((part, index) => {
              const level = levelOf[index];
              const isLit =
                part.role === "always"
                  ? true
                  : part.role === "on"
                    ? on || seeking
                    : part.role === "ring"
                      ? animated
                      : level < lit;

              // A rising change lights from the old level upward; a falling one goes out from the top.
              const rising =
                change.count > 0 &&
                (part.role === "level"
                  ? level >= change.from && level < lit
                  : part.role === "on"
                    ? on && !change.wasOn
                    : part.role === "always" && change.mount);
              const stagger = part.role === "level" ? (level - change.from) * 90 : 0;
              const fallDelay = part.role === "level" ? Math.max(0, change.from - 1 - level) * 60 : 0;

              let loop: string | undefined;
              if (seeking && part.role === "level") {
                loop = `nk-neon-icon-seek-${levels}-${level} 1.9s linear infinite`;
              } else if (charging && part.role === "level" && level >= chargeFrom) {
                loop = `nk-neon-icon-seek-${loopCount}-${level - chargeFrom} 2.2s linear infinite`;
              } else if (animated && part.role === "ring") {
                loop = "nk-neon-icon-wave 2.4s linear infinite";
              } else if (animated && icon === "bolt" && isLit) {
                loop = "nk-neon-icon-surge 2.8s linear infinite";
              } else if (animated && icon === "power" && isLit) {
                loop = "nk-neon-icon-breathe 3.2s ease-in-out infinite";
              }

              const showLit = isLit || Boolean(loop && part.role === "level");
              const stroke = {
                strokeLinecap: part.cap ?? "round",
                strokeLinejoin: "round",
              } as const;

              return (
                <g key={index} data-part={part.role} data-lit={isLit || undefined}>
                  <path
                    d={part.d}
                    strokeWidth={part.width}
                    {...stroke}
                    style={{ stroke: "rgb(var(--neon) / 0.16)" }}
                  />
                  <g
                    style={{
                      opacity: showLit ? 1 : 0,
                      transition: showLit ? "none" : `opacity 180ms ease-out ${fallDelay}ms`,
                    }}
                  >
                    <g
                      key={rising ? change.count : 0}
                      className={cn((rising || loop) && "nk-neon-icon-motion")}
                      style={{
                        animation:
                          loop ??
                          (rising
                            ? part.role === "level"
                              ? `nk-neon-icon-ignite 380ms linear ${stagger}ms both`
                              : "nk-neon-icon-strike 900ms linear both"
                            : undefined),
                      }}
                    >
                      <path
                        d={part.d}
                        strokeWidth={part.width + 0.6}
                        {...stroke}
                        filter={`url(#${uid}-glow)`}
                        style={{ stroke: "rgb(var(--neon))", opacity: "var(--neon-glow, 1)" }}
                      />
                      <path
                        d={part.d}
                        strokeWidth={part.width}
                        {...stroke}
                        style={{ stroke: "rgb(var(--neon))" }}
                      />
                      <path
                        d={part.d}
                        strokeWidth={part.width * 0.42}
                        {...stroke}
                        style={{ stroke: "color-mix(in srgb, rgb(var(--neon)) 30%, white)" }}
                      />
                    </g>
                  </g>
                </g>
              );
            })}
          </g>
        </svg>
      </span>
    );
  },
);
NeonIcon.displayName = "NeonIcon";

export { NeonIcon };
```
