# Input (Lumen Halo): prompt.md (v1.0.0)

- id: `lumen-input` · version 1.0.0 · component · free
- category: Inputs
- build: Base UI (this item also ships a Radix build)
- install (this build): `npx shadcn@latest add https://beautiful-ui.dev/r/lumen-input.json`
- npm dependencies: @base-ui/react@^1
- registry dependencies: utils, https://beautiful-ui.dev/r/lumen-foundation.json
- docs: https://beautiful-ui.dev/components/lumen-input
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

A field that lights up when you arrive and flinches when something's wrong: a filament of light on focus, one coral shake on an error, and 16px text on phones so iOS never zooms. shadcn's Input, cut from Lumen glass.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: `@base-ui/react@^1` (Base UI build); none beyond React (Radix build).
- Files: `components/ui/lumen/input.tsx`; shared code: `lib/beautiful-ui/lumen/sound.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `lumen-foundation`.
- Builds: separate Base UI and Radix files. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `Input`, and every exported type.
- CSS: the install merges this item's rules (the registry `css` field) into your global stylesheet, in `@layer components`, and adds the lumen foundation (tokens, keyframes, motion levels) once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Tokens: retheme with the `--lumen-*` custom properties (`--lumen-accent`, `--lumen-accent-text`, `--lumen-bad`, `--lumen-bad-text`, `--lumen-focus`, `--lumen-good`, `--lumen-good-text`, `--lumen-hairline`, `--lumen-ink`, `--lumen-muted-ink`, `--lumen-series-1`, `--lumen-series-2`, `--lumen-series-3`, `--lumen-series-4`, `--lumen-series-5`, `--lumen-series-6`, `--lumen-warn`, `--lumen-warn-text`); this item's CSS also reads `--lumen-font-sans`, `--lumen-radius-k`. Never add Tailwind colour classes inside the component.

```tsx
import { Input } from "@/components/ui/lumen/input";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `Input` | `input` | The recessed well itself. |

Style a part with `[data-slot="<slot>"]` selectors or its `className`; keep the attributes when editing.

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads.

## Match the original
- Read `components/ui/lumen/input.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- input, text field, email field, password field, form input, shadcn input, Lumen, glass
- Any single-line entry in a Lumen interface: sign-in, settings, search
- A drop-in for shadcn's Input: same export and data-slot
- With Field for the label and messages, or InputGroup for a prefix, suffix or live status

### Not when
- Several lines: use Lumen Textarea
- A unit, prefix or live check in the well: use Lumen Input Group

## Mistakes
- Set aria-invalid only after the person has had a chance (on blur or submit), not on every keystroke
- Import from @/components/ui/lumen/input: it never replaces your own components/ui/input.tsx

## Usage

```tsx
import { Input } from "@/components/ui/lumen/input";
import { Label } from "@/components/ui/lumen/label";

<Label htmlFor="email">Work email</Label>
<Input id="email" type="email" aria-invalid={error ? true : undefined} />
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `aria-invalid` | `boolean` |  | Coral ring and filament, one shake when it turns on, and the error cue through beautiful-ui-sound. |
| `disabled` | `boolean` |  | Dimmed well, not-allowed cursor. |
| `type / value / defaultValue / onChange / …` | `as shadcn` |  | Every native input attribute; ref is the <input>; className merges last with cn. |

Full docs: https://beautiful-ui.dev/components/lumen-input

## Customising
- Colours: set the `--lumen-*` tokens on `:root`, or on a container with the `lumen-scope` class to retheme one area. Add the `lumen-inherit` class to follow your shadcn palette instead (`--chart-N`, `--destructive`).
- Dark mode follows the `.dark` class on an ancestor (the shadcn and next-themes convention).
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| Tab | Focus the field (the filament draws in) |

## Motion inventory

| Interaction | What moves |
|---|---|
| Focus | The filament grows from the centre along the bottom edge (.45s, cubic-bezier(.22,1,.36,1)); the ring turns accent with a 4px halo |
| Invalid | Coral ring and filament; one .38s shake when aria-invalid turns on; the error cue plays |

## Accessibility contract (preserve when editing)
- A native <input> (Base UI Input in the Base UI build)
- Invalid state is aria-invalid, so assistive tech hears it; point aria-describedby at the message
- 16px text on phones, so iOS never zooms the page; 44px tall there
- prefers-reduced-motion: no shake, the filament appears without growing

## Install

Base UI project (a base-* style in components.json):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/lumen-input.json
```

Radix project (a radix-*, new-york or default style):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/radix-nova/lumen-input.json
```

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/lumen-input`: the CLI picks the build from your style.

## Credits
- Built on shadcn/ui (https://ui.shadcn.com)

## Source (Base UI build)

### components/ui/lumen/input.tsx

```tsx
"use client";

/**
 * Input (Lumen Halo) v1.0.0 · Beautiful UI
 * Docs: https://beautiful-ui.dev/components/lumen-input · Agent prompt: https://beautiful-ui.dev/md/lumen-input.md
 * Licensed to the purchaser under the Beautiful UI license: https://beautiful-ui.dev/license
 */

import * as React from "react";
import { Input as InputPrimitive } from "@base-ui/react/input";
import { cn } from "@/lib/utils";
import { statusSpeaksFor, useCueOnChange, useMergedRef } from "@/lib/beautiful-ui/lumen/sound";

/*
 * Lumen Input (Base UI build). shadcn's Input in Lumen Halo: a recessed glass well, 40px tall
 * (44px with 16px text on phones, so iOS never zooms). Focus draws a filament of light along the
 * bottom edge; aria-invalid rings it coral and shakes it once, with an error cue through
 * beautiful-ui-sound. Same export and data-slot as shadcn; put it in a Field for the label and messages,
 * or an InputGroup for a prefix, a suffix or a live status.
 */

function Input({ className, type, ref, ...props }: React.ComponentProps<"input">) {
  const own = React.useRef<HTMLInputElement>(null);
  const invalid = props["aria-invalid"] === true || props["aria-invalid"] === "true";
  useCueOnChange(own, invalid, (now) => (now && !statusSpeaksFor(own.current) ? "error" : null));
  const merged = useMergedRef(own, ref);
  return <InputPrimitive type={type} ref={merged} data-slot="input" className={cn(`lumen-input`, className)} {...props} />;
}

export { Input };
```

### lib/beautiful-ui/lumen/sound.ts

```tsx
"use client";

import * as React from "react";

/**
 * Asks the page's sound layer (beautiful-ui-sound) to play `cue`. Silent when nothing listens, and inside
 * anything marked data-sound="off". `index` pitches select cues by position. No audio code ships in
 * the components themselves.
 */
export function emitSound(el: Element | null, cue: string, force?: boolean, index?: number) {
  if (!el || typeof CustomEvent === "undefined" || el.closest('[data-sound="off"]')) return;
  el.dispatchEvent(new CustomEvent("beautiful-ui:sound", { bubbles: true, detail: { cue, force, index } }));
}

/**
 * Plays the cue `pick` returns whenever `value` changes (never on mount): an error when a field
 * turns invalid, a success when a check passes.
 */
export function useCueOnChange<T>(ref: React.RefObject<Element | null>, value: T, pick: (next: T, prev: T) => string | null) {
  const prev = React.useRef(value);
  const pickRef = React.useRef(pick);
  React.useEffect(() => {
    pickRef.current = pick;
  });
  React.useEffect(() => {
    if (Object.is(prev.current, value)) return;
    const before = prev.current;
    prev.current = value;
    const cue = pickRef.current(value, before);
    if (cue) emitSound(ref.current, cue);
  }, [ref, value]);
}

/** Points one ref (callback or object) at `node`; returns how to let go of it. */
function assign<T>(r: React.Ref<T> | undefined, node: T | null): () => void {
  if (typeof r === "function") {
    const cleanup = r(node);
    return typeof cleanup === "function" ? cleanup : () => r(null);
  }
  if (r) {
    (r as React.RefObject<T | null>).current = node;
    return () => ((r as React.RefObject<T | null>).current = null);
  }
  return () => {};
}

/**
 * One stable callback ref for the component's own ref and the caller's. It changes only when one of
 * them does, so a caller's callback ref isn't detached and reattached on every render, and it
 * honours React 19 ref cleanups.
 */
export function useMergedRef<T>(own: React.Ref<T> | undefined, theirs: React.Ref<T> | undefined): React.RefCallback<T> {
  return React.useCallback(
    (node: T | null) => {
      const release = [assign(own, node), assign(theirs, node)];
      return () => release.forEach((f) => f());
    },
    [own, theirs],
  );
}

/** True when `el` sits in an input group whose status part speaks for it (one cue per change). */
export const statusSpeaksFor = (el: Element | null) => Boolean(el?.closest('[data-slot="input-group"]')?.querySelector('[data-slot="input-group-status"]'));
```

### CSS (the registry `css` / `cssVars`, merged into the global stylesheet by the shadcn CLI)

```css
@layer components {
  .lumen-input {
    box-sizing: border-box;
    width: 100%;
    min-width: 0;
    margin: 0;
    border: none;
    outline: none;
    --lf-ring: var(--gc-wr);
    --lf-halo: 0 0 0 0 transparent;
    background-color: var(--lf-bg,var(--gc-well));
    box-shadow: inset 0 0 0 1px var(--lf-ring),var(--gc-wellIn),var(--lf-halo);
    background-image: linear-gradient(90deg,transparent,var(--lf-fil,var(--gc-acc)),transparent);
    background-repeat: no-repeat;
    background-position: 50% 100%;
    background-size: 0% 1px;
    font-family: var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    font-weight: 400;
    font-size: var(--lf-fs,14px);
    letter-spacing: normal;
    color: var(--gc-ink);
    caret-color: var(--gc-acc);
    -webkit-appearance: none;
    appearance: none;
    transition: box-shadow var(--gc-t-c) ease,background-size var(--gc-t-fil) cubic-bezier(.22,1,.36,1),background-color var(--gc-t-c) ease;
    height: 40px;
    padding: 0 12px;
    border-radius: calc(12px * var(--lumen-radius-k, 1));
    text-overflow: ellipsis;
  }
  .lumen-input::placeholder {
    color: var(--gc-ph);
    opacity: 1;
  }
  @media (hover:hover) {
    .lumen-input:hover:not(:disabled) {
      --lf-ring: var(--gc-wrH);
    }
  }
  .lumen-input:focus,.lumen-input[data-force="focus"] {
    --lf-ring: color-mix(in srgb, var(--gc-acc) 65%, transparent);
    --lf-halo: 0 0 0 4px color-mix(in srgb, var(--gc-acc) 12%, transparent);
    background-size: calc(100% - 28px) 1px;
  }
  .lumen-input[aria-invalid="true"] {
    --lf-ring: color-mix(in srgb, var(--gc-bad) 60%, transparent);
    --lf-halo: 0 0 0 4px color-mix(in srgb, var(--gc-bad) 8%, transparent);
    --lf-fil: var(--gc-bad);
    background-size: calc(100% - 28px) 1px;
    animation: lumen-shake-a calc(.38s * var(--gcp-k, 1)) cubic-bezier(.22,1,.36,1);
  }
  .lumen-input:disabled {
    --lf-ring: var(--gc-wr);
    --lf-bg: var(--gc-wellD);
    opacity: .5;
    cursor: not-allowed;
    -webkit-text-fill-color: var(--gc-ink);
  }
  .lumen-input:-webkit-autofill {
    -webkit-text-fill-color: var(--gc-ink);
    transition: background-color 100000s 0s;
  }
  .lumen-input::file-selector-button {
    height: 26px;
    margin: 0 10px 0 -4px;
    padding: 0 10px;
    border: none;
    border-radius: calc(8px * var(--lumen-radius-k, 1));
    background: var(--gc-preBg);
    color: var(--gc-ink);
    font: 500 12px var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    cursor: pointer;
  }
  @media (max-width: 699px) {
    .lumen-input {
      height: 44px;
      --lf-fs: 16px;
    }
  }
}
```

The lumen foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://beautiful-ui.dev/r/lumen-foundation.json.
