# NeonKit --- > Neon UI components for React and Tailwind CSS. 26 accessible, copy-paste components with a glow, one CSS variable for color, installable with the shadcn CLI. --- ## Installation NeonKit needs React 18+ and Tailwind CSS 3.4 or 4. Components are published as a shadcn registry. Add one with the shadcn CLI (run `npx shadcn@latest init` first in a new project): ```bash npx shadcn@latest add https://get-neonkit.vercel.app/r/switch.json ``` Or register the namespace once in `components.json` and use short names: ```json { "registries": { "@neonkit": "https://get-neonkit.vercel.app/r/{name}.json" } } ``` ```bash npx shadcn@latest add @neonkit/switch @neonkit/tabs @neonkit/toast ``` The CLI writes `components/ui/.tsx`, installs npm dependencies and adds `lib/neon.ts`. Use `--overwrite` to replace an existing shadcn file with the same name. Manual install: `npm install clsx tailwind-merge`, add `lib/utils.ts` (exports `cn`) and `lib/neon.ts`, then paste a component's source into `components/ui`. Components import from `@/lib/utils` and `@/lib/neon`. NeonKit is dark only. Glows are tuned for backgrounds near `#05060a`. --- ### lib/utils.ts ```ts import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } ``` --- ### lib/neon.ts ```ts import type * as React from "react"; /** * The NeonKit palette, stored as space-separated RGB channels so components * can apply any alpha with `rgb(var(--neon) / 0.4)`. */ export const neonColors = { cyan: "34 211 238", blue: "79 139 255", violet: "168 85 247", magenta: "240 70 235", pink: "255 95 162", red: "255 77 94", orange: "255 138 61", amber: "251 191 36", lime: "163 230 53", green: "52 211 153", white: "230 237 247", } as const; export type NeonColorName = keyof typeof neonColors; /** A palette name or any hex color (`#0ff`, `#ff00aa`). */ export type NeonColor = NeonColorName | (string & {}); export const neonColorNames = Object.keys(neonColors) as NeonColorName[]; /** Resolves a palette name or hex string to `"r g b"` channels. */ export function toNeonRgb(color: NeonColor): string { if (color in neonColors) return neonColors[color as NeonColorName]; let hex = color.trim().replace(/^#/, ""); if (hex.length === 3) hex = hex.replace(/./g, (c) => c + c); if (!/^[0-9a-f]{6}$/i.test(hex)) return neonColors.cyan; const value = parseInt(hex, 16); return `${(value >> 16) & 255} ${(value >> 8) & 255} ${value & 255}`; } /** * Inline style that sets a component's neon color. * * Components read `--neon`. Leave `color` undefined and they inherit * `--neon-accent` from any ancestor, falling back to cyan. */ export function neonVars( color?: NeonColor, style?: React.CSSProperties, ): React.CSSProperties | undefined { if (!color) return style; const rgb = toNeonRgb(color); return { "--neon": rgb, "--neon-accent": rgb, ...style } as React.CSSProperties; } ``` --- ## Theming Every component reads its color from one CSS variable, `--neon`, holding space-separated RGB channels. - Per component: ` ``` ## Examples ### Variants ```tsx import { ArrowRight } from "lucide-react"; import { Button } from "@/components/ui/button"; export default function ButtonDemo() { return (
); } ``` ### Colors Pass a palette name or any hex value to color. ```tsx import { Button } from "@/components/ui/button"; const colors = ["cyan", "blue", "violet", "magenta", "pink", "red", "orange", "amber", "lime", "green"] as const; export default function ButtonColors() { return (
{colors.map((color) => ( ))}
{colors.map((color) => ( ))}
); } ``` ### Sizes ```tsx import { Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; export default function ButtonSizes() { return (
); } ``` ### Loading and disabled loading swaps in a spinner and blocks clicks until it clears. ```tsx "use client"; import * as React from "react"; import { Button } from "@/components/ui/button"; export default function ButtonLoading() { const [loading, setLoading] = React.useState(false); function save() { setLoading(true); setTimeout(() => setLoading(false), 1800); } return (
); } ``` ### As a link asChild hands the styles to its child, so links keep their semantics. ```tsx import { ArrowUpRight } from "lucide-react"; import { Button } from "@/components/ui/button"; export default function ButtonAsLink() { return ( ); } ``` ## API reference ### Button Also accepts every native button attribute. | Prop | Type | Default | Description | | --- | --- | --- | --- | | `variant` | `"solid" \| "outline" \| "soft" \| "ghost" \| "link"` | `"outline"` | Visual style. | | `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. | | `size` | `"sm" \| "md" \| "lg" \| "icon"` | `"md"` | Height and padding. | | `loading` | `boolean` | `false` | Shows a spinner and disables the button. | | `asChild` | `boolean` | `false` | Merges props onto the single child element instead of rendering a button. | ## Accessibility - Icon-only buttons need an aria-label. - loading sets aria-busy so screen readers announce the pending state. ## Source `components/ui/button.tsx` ```tsx "use client"; import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { LoaderCircle } from "lucide-react"; import { cn } from "@/lib/utils"; import { neonVars, type NeonColor } from "@/lib/neon"; const variants = { solid: "bg-[rgb(var(--neon))] text-[#05060a] shadow-[0_0_0_1px_rgb(var(--neon)),0_0_18px_rgb(var(--neon)/0.45),inset_0_1px_0_rgb(255_255_255/0.45)] hover:shadow-[0_0_0_1px_rgb(var(--neon)),0_0_32px_rgb(var(--neon)/0.7),inset_0_1px_0_rgb(255_255_255/0.45)] hover:brightness-110", outline: "border border-[rgb(var(--neon)/0.7)] bg-[rgb(var(--neon)/0.06)] text-[rgb(var(--neon))] shadow-[0_0_12px_rgb(var(--neon)/0.25),inset_0_0_12px_rgb(var(--neon)/0.12)] [text-shadow:0_0_10px_rgb(var(--neon)/0.6)] hover:border-[rgb(var(--neon))] hover:bg-[rgb(var(--neon)/0.12)] hover:shadow-[0_0_24px_rgb(var(--neon)/0.5),inset_0_0_16px_rgb(var(--neon)/0.2)]", soft: "bg-[rgb(var(--neon)/0.12)] text-[rgb(var(--neon))] hover:bg-[rgb(var(--neon)/0.2)]", ghost: "text-white/70 hover:bg-[rgb(var(--neon)/0.1)] hover:text-[rgb(var(--neon))]", link: "h-auto px-0 text-[rgb(var(--neon))] underline-offset-4 hover:underline hover:[text-shadow:0_0_12px_rgb(var(--neon)/0.7)]", } as const; const sizes = { sm: "h-8 gap-1.5 rounded-md px-3 text-xs", md: "h-10 gap-2 rounded-lg px-4 text-sm", lg: "h-12 gap-2.5 rounded-xl px-6 text-base", icon: "size-10 rounded-lg", } as const; export interface ButtonProps extends Omit, "color"> { /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */ color?: NeonColor; variant?: keyof typeof variants; size?: keyof typeof sizes; /** Shows a spinner and disables the button. */ loading?: boolean; /** Render the child element (e.g. a link) with button styles. */ asChild?: boolean; } const Button = React.forwardRef( ( { className, style, color, variant = "outline", size = "md", loading = false, asChild = false, disabled, children, ...props }, ref, ) => { const Comp = asChild ? Slot : "button"; return ( {asChild ? ( children ) : ( <> {loading && } {children} )} ); }, ); Button.displayName = "Button"; export { Button }; ``` --- # Component: Input > Text field with an animated neon focus ring, icons, labels and error states. Category: Forms. Docs: https://get-neonkit.vercel.app/components/input ## Installation ```bash npx shadcn@latest add https://get-neonkit.vercel.app/r/input.json ``` With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/input`. npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds. ## Usage ```tsx import { Input } from "@/components/ui/input" ``` ## Examples ### Sign in ```tsx "use client"; import * as React from "react"; import { Eye, EyeOff, LockKeyhole, Mail } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; export default function InputDemo() { const [visible, setVisible] = React.useState(false); return (
event.preventDefault()}> } /> } trailing={ } />
); } ``` ### Search with a shortcut trailing holds hints and small buttons. Press / to focus this one. ```tsx "use client"; import * as React from "react"; import { Search } from "lucide-react"; import { Input } from "@/components/ui/input"; export default function InputSearch() { const ref = React.useRef(null); const [focused, setFocused] = React.useState(false); React.useEffect(() => { function onKeyDown(event: KeyboardEvent) { if (event.defaultPrevented || event.key !== "/") return; const target = event.target as HTMLElement; // Let people type a slash into other fields. if (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return; event.preventDefault(); ref.current?.focus(); } window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, []); return ( } trailing={ {focused ? "Esc" : "/"} } onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} onKeyDown={(event) => { if (event.key === "Escape") event.currentTarget.blur(); }} containerClassName="max-w-sm" /> ); } ``` ### Validation error turns the field red, sets aria-invalid and takes the description's place. leading also takes text addons. ```tsx "use client"; import * as React from "react"; import { Check } from "lucide-react"; import { Input } from "@/components/ui/input"; const taken = ["aadi", "admin", "neon", "support"]; function validate(handle: string) { if (!handle) return "Pick a handle for your profile link."; if (!/^[a-z0-9-]+$/.test(handle)) return "Use lowercase letters, numbers and dashes."; if (handle.length < 3) return "Handles are at least 3 characters."; if (taken.includes(handle)) return `That one is taken. Try ${handle}-dev.`; return undefined; } export default function InputValidation() { const [handle, setHandle] = React.useState("aadi"); const error = validate(handle); return ( setHandle(event.target.value.trim())} error={error} spellCheck={false} autoCapitalize="none" leading={neonkit.dev/} trailing={error ? null : } containerClassName="max-w-sm" /> ); } ``` ### Read-only and disabled Read-only fields keep a quieter focus state so they don't read as editable. ```tsx "use client"; import * as React from "react"; import { Check, Copy } from "lucide-react"; import { Input } from "@/components/ui/input"; const apiKey = "nk_live_51Hx9Q2eZvKYlo2C8Nf3"; export default function InputStates() { const [copied, setCopied] = React.useState(false); async function copy() { await navigator.clipboard.writeText(apiKey); setCopied(true); setTimeout(() => setCopied(false), 1500); } return (
event.currentTarget.select()} trailing={ } />
); } ``` ### Sizes Heights match Button: 32, 40 and 48px. ```tsx import { Search } from "lucide-react"; import { Input } from "@/components/ui/input"; export default function InputSizes() { return (
} /> } /> } />
); } ``` ## API reference ### Input Also accepts every native input attribute, and the ref points at the . className and style go to the field box around it. | Prop | Type | Default | Description | | --- | --- | --- | --- | | `label` | `ReactNode` | — | Visible label, linked to the input. | | `description` | `ReactNode` | — | Helper text under the field. | | `error` | `ReactNode` | — | Red invalid state and message. Replaces description. Pass true for the state without a message. | | `leading` | `ReactNode` | — | Icon or text addon before the value. Icons light up on focus. | | `trailing` | `ReactNode` | — | Icon, hint or small button after the value. | | `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. | | `size` | `"sm" \| "md" \| "lg"` | `"md"` | Height, padding and text size. | | `className` | `string` | — | Classes for the field box: border, background, height. | | `containerClassName` | `string` | — | Classes for the outer block with the label and message. Use it for width and margins. | ## Accessibility - The label is a native