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

- id: `lumen-input-group` · 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-group.json`
- npm dependencies: class-variance-authority@^0.7
- registry dependencies: utils, @beautiful-ui/lumen-button, @beautiful-ui/lumen-input, @beautiful-ui/lumen-textarea, https://beautiful-ui.dev/r/lumen-foundation.json
- docs: https://beautiful-ui.dev/components/lumen-input-group
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

Everything a field needs to say, said inside the field: a tinted prefix, a key cap, a live check that ticks, then draws its tick, and a meter that counts in cells. shadcn's InputGroup, 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: `class-variance-authority@^0.7`.
- Files: `components/ui/lumen/input-group.tsx`; shared code: `lib/beautiful-ui/lumen/glyphs.tsx`, `lib/beautiful-ui/lumen/meter.ts`, `lib/beautiful-ui/lumen/sound.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `lumen-button`, `lumen-input`, `lumen-textarea`, `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: `InputGroup`, `InputGroupAddon`, `InputGroupText`, `InputGroupInput / InputGroupTextarea`, `InputGroupButton`, `InputGroupStatus`, `InputGroupCount`, 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-mono`, `--lumen-font-sans`, `--lumen-radius-k`. Never add Tailwind colour classes inside the component.

```tsx
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupStatus, InputGroupText } from "@/components/ui/lumen/input-group";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `InputGroup` | `input-group` | The well around the control and its addons. |
| `InputGroupAddon` | `input-group-addon` | Before, after, above or below the control. |
| `InputGroupText` | `input-group-text` | A prefix or unit. |
| `InputGroupInput / InputGroupTextarea` | `input-group-control` | The control, giving its well to the group. |
| `InputGroupButton` | — | A Lumen Button sized for the well. |
| `InputGroupStatus` | `input-group-status` | Beautiful UI: the live check. |
| `InputGroupCount` | `input-group-count` | Beautiful UI: the cell meter. |

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-group.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 group, input with prefix, url input, search with shortcut, availability check, username check, character count, textarea with button, shadcn input group, Lumen
- A prefix or unit in the well: lumen.app/, USD, a search icon
- Checking a value as it is typed: InputGroupStatus shows checking, available or invalid
- Text limits: InputGroupCount under a textarea
- Actions inside the field: InputGroupButton

### Not when
- A plain field: use Lumen Input

## Mistakes
- Also set aria-invalid on the control for the coral well: the status alone doesn't mark it invalid
- Debounce checks: set status to "checking" while you wait
- Import from @/components/ui/lumen/input-group: it never replaces your own

## Usage

```tsx
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupStatus, InputGroupText } from "@/components/ui/lumen/input-group";

<InputGroup>
  <InputGroupAddon>
    <InputGroupText>lumen.app/</InputGroupText>
  </InputGroupAddon>
  <InputGroupInput value={slug} onChange={check} aria-invalid={status === "error"} />
  <InputGroupAddon align="inline-end">
    <InputGroupStatus status={status} />
  </InputGroupAddon>
</InputGroup>
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `InputGroupAddon align` | `"inline-start" \| "inline-end" \| "block-start" \| "block-end"` | `"inline-start"` | Where it sits; text (InputGroupText) at the start goes on the tinted mono strip, icons stay bare. |
| `InputGroupStatus status` | `"checking" \| "ok" \| "error" \| null` |  | Beautiful UI: ticker and CHECKING, the check drawing in and AVAILABLE, or !; announced; success and error cues. |
| `InputGroupStatus labels` | `{ checking?, ok?, error? }: { word?, said? }` |  | The visible word and the announcement per status ('' hides the word). |
| `InputGroupCount length / limit / cells` | `number / number / number` | `— / — / 20` | Beautiful UI: the cell meter and count; amber from 90%, coral past the limit. |
| `InputGroupButton variant / size` | `Lumen Button variant / "xs" \| "sm" \| "icon-xs" \| "icon-sm"` | `"ghost" / "xs"` | A Lumen Button sized for the well. |

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

## 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 control, then any button in an addon |

## Motion inventory

| Interaction | What moves |
|---|---|
| Focus | The filament fades in and stretches from 20% to full width (.45s); accent ring and halo |
| Status | Checking ticks four cells; available draws the check in over .5s with the success cue; invalid rings coral, shakes once and plays the error cue |
| Count | Cells fill as you type; the last 10% turn amber, past the limit every cell turns coral |

## Accessibility contract (preserve when editing)
- role=group around the control and its addons; clicking an addon focuses the control
- InputGroupStatus speaks each change in a polite live region; the glyphs and words are hidden from screen readers
- InputGroupCount reads "124 of 140 characters"; point the textarea's aria-describedby at it
- 44px wells and 16px text on phones

## Install

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

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

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

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

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/lumen-input-group`: 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-group.tsx

```tsx
"use client";

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

import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/lumen/button";
import { Input } from "@/components/ui/lumen/input";
import { Textarea } from "@/components/ui/lumen/textarea";
import { LumenCheckGlyph, LumenErrorGlyph, LumenTicker } from "@/lib/beautiful-ui/lumen/glyphs";
import { meterCells, meterTone } from "@/lib/beautiful-ui/lumen/meter";
import { emitSound, useMergedRef } from "@/lib/beautiful-ui/lumen/sound";

/*
 * Lumen Input Group (Base UI build). shadcn's InputGroup in Lumen Halo: the group is the
 * recessed well; text before the value sits in mono on a tinted strip with a hairline (icons go
 * bare); focus draws the filament along the bottom; an invalid control rings it coral and shakes it.
 * Same exports, parts and data-slots as shadcn, plus two Beautiful UI parts: InputGroupStatus (checking,
 * available, invalid, announced, with success and error cues) and InputGroupCount (the cell meter).
 */

function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
  return <div data-slot="input-group" role="group" className={cn(`lumen-input-group`, className)} {...props} />;
}

const inputGroupAddonVariants = cva(`lumen-input-group-addon`, {
  variants: { align: { "inline-start": "", "inline-end": "", "block-start": "", "block-end": "" } },
  defaultVariants: { align: "inline-start" },
});

function InputGroupAddon({ className, align = "inline-start", onClick, ...props }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
  return (
    <div
      role="group"
      data-slot="input-group-addon"
      data-align={align}
      className={cn(inputGroupAddonVariants({ align }), className)}
      onClick={(e) => {
        onClick?.(e);
        // Clicking the addon focuses the control (not when it hits a button, or you prevented it).
        if (e.defaultPrevented || (e.target as HTMLElement).closest("button")) return;
        e.currentTarget.parentElement?.querySelector<HTMLElement>("input, textarea")?.focus();
      }}
      {...props}
    />
  );
}

function InputGroupButton({
  className,
  type = "button",
  variant = "ghost",
  size = "xs",
  ...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> & { size?: "xs" | "sm" | "icon-xs" | "icon-sm"; type?: "button" | "submit" | "reset" }) {
  return <Button type={type} data-size={size} variant={variant} size={size} className={className} {...props} />;
}

function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
  return <span data-slot="input-group-text" className={cn(`lumen-input-group-text`, className)} {...props} />;
}

function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
  return <Input data-slot="input-group-control" className={className} {...props} />;
}

function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
  return <Textarea data-slot="input-group-control" className={className} {...props} />;
}

type Status = "checking" | "ok" | "error";
const STATUS_WORDS: Record<Status, { word: string; said: string }> = {
  checking: { word: "Checking", said: "Checking…" },
  ok: { word: "Available", said: "Available" },
  error: { word: "", said: "Invalid" },
};

/**
 * Beautiful UI: the live status at the end of the well (put it in an inline-end addon). The ticker and
 * CHECKING, the check drawing in and AVAILABLE, or the ! glyph; the word hides when the well is
 * under 320px. It tints the whole well (checking, ok) through CSS, so the first paint is right.
 * Announced politely; plays success or error through beautiful-ui-sound when it changes.
 */
function InputGroupStatus({
  status,
  labels,
  className,
  ref,
  ...props
}: Omit<React.ComponentProps<"span">, "children"> & { status: Status | null | undefined; labels?: Partial<Record<Status, { word?: string; said?: string }>> }) {
  const own = React.useRef<HTMLSpanElement>(null);
  const merged = useMergedRef(own, ref);
  const prev = React.useRef(status);
  React.useEffect(() => {
    if (prev.current === status) return;
    prev.current = status;
    // The status speaks for its group (the control stays quiet), so each change plays one cue.
    if (status === "ok") emitSound(own.current, "success");
    if (status === "error") emitSound(own.current, "error");
  }, [status]);
  const text = status ? { ...STATUS_WORDS[status], ...labels?.[status] } : null;
  return (
    <span ref={merged} data-slot="input-group-status" data-status={status ?? undefined} className={cn(`lumen-input-group-status`, className)} {...props}>
      <span className="sr-only" role="status" aria-live="polite">
        {text?.said ?? ""}
      </span>
      {status === "checking" && (
        <>
          <LumenTicker />
          {text?.word && <span aria-hidden className={`lumen-input-group-word`}>{text.word}</span>}
        </>
      )}
      {status === "ok" && (
        <>
          <LumenCheckGlyph />
          {text?.word && <span aria-hidden className={`lumen-input-group-word`}>{text.word}</span>}
        </>
      )}
      {status === "error" && <LumenErrorGlyph />}
    </span>
  );
}

/**
 * Beautiful UI: the count as a cell meter and a number (put it in a block-end addon under a textarea).
 * Accent cells, amber from 90% of the limit, coral past it. Give the textarea
 * aria-describedby={id} of this part so the count is read on focus.
 */
function InputGroupCount({ length, limit, cells = 20, className, ...props }: Omit<React.ComponentProps<"div">, "children"> & { length: number; limit: number; cells?: number }) {
  const tone = meterTone(length, limit);
  return (
    <div data-slot="input-group-count" className={cn(`lumen-input-group-count`, className)} {...props}>
      <span aria-hidden className={`lumen-input-group-cells`}>
        {meterCells(length, limit, cells).map((c, i) => (
          <span key={i} data-t={c.tone} style={{ opacity: c.opacity }} />
        ))}
      </span>
      <span className={`lumen-input-group-number`} data-t={tone}>
        <span aria-hidden>{`${length} / ${limit}`}</span>
        <span className="sr-only">{`${length} of ${limit} characters`}</span>
      </span>
    </div>
  );
}

export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea, InputGroupStatus, InputGroupCount };
```

### lib/beautiful-ui/lumen/glyphs.tsx

```tsx
import type * as React from "react";

/*
 * Lumen's glyphs for its shadcn-built controls: the 4-cell loading ticker, the check that draws
 * itself in, the ! glyph, and the cell-drawn icon set. The same drawings as the glass family.
 */

const SVG = { width: "1.1em", height: "1.1em", viewBox: "0 0 16 16", fill: "none", "aria-hidden": true, focusable: false } as const;
const BOX: React.CSSProperties = { display: "block", flex: "none" };
const ST = { stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", fill: "none" } as const;
const cell = (x: number, y: number, s = 3) => <rect key={`${x}-${y}`} x={x} y={y} width={s} height={s} rx={0.9} fill="currentColor" />;

/** The 4-cell loading ticker in currentColor (.32em cells, .16em gaps). */
export function LumenTicker({ className }: { className?: string }) {
  return (
    <span aria-hidden className={className ? `lumen-tick ${className}` : `lumen-tick`}>
      <span />
      <span />
      <span />
      <span />
    </span>
  );
}

/** The check that draws itself in (stroke-dashoffset 16 → 0). Remount it (key) to replay. */
export function LumenCheckGlyph() {
  return (
    <svg {...SVG} className={`lumen-check`} style={BOX}>
      <path d="M3.5 8.4l2.9 2.9 6.1-6.6" {...ST} strokeWidth={1.9} />
    </svg>
  );
}

/** The ! glyph, cropped tight so its gap to a label matches the other glyphs. */
export function LumenErrorGlyph() {
  return (
    <svg {...SVG} width=".36em" viewBox="6.2 0 3.6 16" style={{ ...BOX, margin: "0 1px 0 -1px" }}>
      <rect x={6.9} y={2.6} width={2.2} height={7} rx={1.1} fill="currentColor" />
      <rect x={6.9} y={11.2} width={2.2} height={2.2} rx={0.8} fill="currentColor" />
    </svg>
  );
}

export const LUMEN_ICON_NAMES = ["plus", "grid", "more", "search", "filter", "copy", "arrow", "trash", "close", "check"] as const;
export type LumenIconName = (typeof LUMEN_ICON_NAMES)[number];
export const isLumenIconName = (v: unknown): v is LumenIconName => typeof v === "string" && (LUMEN_ICON_NAMES as readonly string[]).includes(v);

/** One icon from the Lumen set, 1.1em square in currentColor: plus, grid and more are cells; the rest are 1.6px strokes. */
export function LumenIcon({ name }: { name: LumenIconName }) {
  const p = { ...SVG, style: BOX };
  switch (name) {
    case "plus":
      return <svg {...p}>{[cell(6.5, 2.5), cell(2.5, 6.5), cell(6.5, 6.5), cell(10.5, 6.5), cell(6.5, 10.5)]}</svg>;
    case "more":
      return <svg {...p}>{[cell(2, 6.5), cell(6.5, 6.5), cell(11, 6.5)]}</svg>;
    case "grid":
      return <svg {...p}>{[cell(3, 3, 4), cell(9, 3, 4), cell(3, 9, 4), cell(9, 9, 4)]}</svg>;
    case "search":
      return (
        <svg {...p}>
          <circle cx={7} cy={7} r={4.25} {...ST} />
          <path d="M10.3 10.3L13.5 13.5" {...ST} />
        </svg>
      );
    case "copy":
      return (
        <svg {...p}>
          <rect x={5.5} y={5.5} width={8} height={8} rx={2} {...ST} />
          <path d="M10.5 5.5V4a1.5 1.5 0 0 0-1.5-1.5H4A1.5 1.5 0 0 0 2.5 4v5A1.5 1.5 0 0 0 4 10.5h1.5" {...ST} />
        </svg>
      );
    case "trash":
      return (
        <svg {...p}>
          <path d="M2.8 4.5h10.4M6.4 4.5V3h3.2v1.5M4.3 4.5l.6 8.5h6.2l.6-8.5" {...ST} />
        </svg>
      );
    case "arrow":
      return (
        <svg {...p}>
          <path d="M3 8h10M9 4l4 4-4 4" {...ST} />
        </svg>
      );
    case "close":
      return (
        <svg {...p}>
          <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" {...ST} />
        </svg>
      );
    case "filter":
      return (
        <svg {...p}>
          <path d="M2.5 4.5h11M4.5 8h7M6.5 11.5h3" {...ST} />
        </svg>
      );
    case "check":
      return <LumenCheckGlyph />;
  }
}
```

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

```tsx
/*
 * The Lumen cell meter: a count drawn as cells (accent, amber from 90% of the limit, coral past it),
 * the same maths as the glass textarea. Pure, for tests and your own meters.
 */

export type MeterTone = "ok" | "near" | "over";
export type MeterCell = { fill: number; tone: MeterTone | "off"; opacity: number };

/** "over" past the limit, "near" from 90% of it, else "ok". A limit of 0 or less is always "ok". */
export function meterTone(length: number, limit: number | undefined): MeterTone {
  if (!limit || limit <= 0) return "ok";
  if (length > limit) return "over";
  return length >= limit * 0.9 ? "near" : "ok";
}

/**
 * `cells` cells for `length` of `limit`; the cell being filled is partial (opacity .3 → 1).
 * @example meterCells(124, 280, 20)[8] // { fill: 0.857…, tone: "ok", opacity: 0.9 }
 */
export function meterCells(length: number, limit: number, cells = 20): MeterCell[] {
  const n = Math.max(1, Math.round(cells));
  const over = limit > 0 && length > limit;
  const f = limit > 0 ? (Math.max(0, length) / limit) * n : 0;
  return Array.from({ length: n }, (_, i) => {
    const fill = over ? 1 : Math.max(0, Math.min(1, f - i));
    const tone: MeterCell["tone"] = fill <= 0 ? "off" : over ? "over" : i >= n * 0.9 ? "near" : "ok";
    return { fill, tone, opacity: fill > 0 ? 0.3 + 0.7 * fill : 1 };
  });
}

/**
 * How many of a mixed checkbox's 3 cells are lit for `fraction` of its children on: 1–3, rounding up
 * so every step shows (1, 2, 3 of 4 light 1, 2, 3 cells; the tinted box still says "not all").
 */
export function mixedCells(fraction = 2 / 3): number {
  const f = Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : 2 / 3;
  return Math.min(3, Math.max(1, Math.ceil(f * 3 - 1e-9)));
}
```

### 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;
  }
  .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;
  }
  .lumen-textarea {
    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;
    display: block;
    min-height: var(--lf-min-h,96px);
    padding: 12px 14px;
    border-radius: calc(14px * var(--lumen-radius-k, 1));
    line-height: 1.55;
    resize: vertical;
    field-sizing: content;
  }
  .lumen-textarea::placeholder {
    color: var(--gc-ph);
    opacity: 1;
  }
  .lumen-textarea:focus,.lumen-textarea[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-textarea[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-textarea:disabled {
    --lf-ring: var(--gc-wr);
    --lf-bg: var(--gc-wellD);
    opacity: .5;
    cursor: not-allowed;
    -webkit-text-fill-color: var(--gc-ink);
  }
  .lumen-textarea:-webkit-autofill {
    -webkit-text-fill-color: var(--gc-ink);
    transition: background-color 100000s 0s;
  }
  .lumen-input-group {
    position: relative;
    display: flex;
    align-items: stretch;
    box-sizing: border-box;
    width: 100%;
    min-width: 0;
    height: 40px;
    border-radius: calc(12px * var(--lumen-radius-k, 1));
    --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);
    cursor: text;
    font-family: var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    transition: box-shadow var(--gc-t-c) ease,background-color var(--gc-t-c) ease;
    container: lumen-input-group/inline-size;
  }
  .lumen-input-group::after {
    content: "";
    position: absolute;
    left: 14px;
    right: 14px;
    bottom: 0;
    height: 1px;
    background: linear-gradient(90deg,transparent,var(--gc-acc),transparent);
    opacity: 0;
    transform: scaleX(.2);
    transition: opacity var(--gc-t-fil-o) ease,transform var(--gc-t-fil) cubic-bezier(.22,1,.36,1);
    pointer-events: none;
  }
  @media (hover:hover) {
    .lumen-input:hover:not(:disabled) {
      --lf-ring: var(--gc-wrH);
    }
    .lumen-textarea:hover:not(:disabled) {
      --lf-ring: var(--gc-wrH);
    }
    .lumen-input-group:hover:not(:has(:disabled)) {
      --lf-ring: var(--gc-wrH);
    }
  }
  .lumen-input-group:focus-within,.lumen-input-group[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);
  }
  .lumen-input-group:focus-within::after,.lumen-input-group[data-force="focus"]::after,.lumen-input-group:has([aria-invalid="true"])::after {
    opacity: 1;
    transform: scaleX(1);
  }
  .lumen-input-group:has([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);
    animation: lumen-shake-a calc(.38s * var(--gcp-k, 1)) cubic-bezier(.22,1,.36,1);
  }
  .lumen-input-group:has([aria-invalid="true"])::after {
    background: linear-gradient(90deg,transparent,var(--gc-bad),transparent);
  }
  .lumen-input-group:has([data-slot="input-group-status"][data-status="ok"]):not(:focus-within):not(:has([aria-invalid="true"])) {
    --lf-ring: color-mix(in srgb, var(--gc-good) 45%, transparent);
  }
  .lumen-input-group:has([data-slot="input-group-status"][data-status="checking"]):not(:has([aria-invalid="true"])) {
    --lf-ring: color-mix(in srgb, var(--gc-acc) 32%, transparent);
    --lf-halo: 0 0 0 0 transparent;
  }
  .lumen-input-group:has([data-slot="input-group-status"][data-status="checking"]):not(:has([aria-invalid="true"]))::after {
    opacity: 0;
    transform: scaleX(.2);
  }
  .lumen-input-group:has(>[data-slot="input-group-control"]:disabled) {
    --lf-ring: var(--gc-wr);
    --lf-bg: var(--gc-wellD);
    opacity: .5;
    cursor: not-allowed;
  }
  .lumen-input-group>.lumen-input,.lumen-input-group>.lumen-textarea {
    flex: 1;
    height: auto;
    background: none;
    box-shadow: none;
    border-radius: 0;
    animation: none;
    opacity: 1;
  }
  .lumen-input-group>.lumen-input {
    padding: 0 12px;
  }
  .lumen-input-group:has(>.lumen-textarea),.lumen-input-group:has(>[data-align="block-start"]),.lumen-input-group:has(>[data-align="block-end"]) {
    flex-direction: column;
    height: auto;
    border-radius: calc(14px * var(--lumen-radius-k, 1));
  }
  .lumen-input-group:has(>[data-align="block-start"],>[data-align="block-end"])>.lumen-input {
    height: 40px;
  }
  .lumen-input-group>.lumen-textarea {
    padding: 12px 14px 10px;
    resize: none;
  }
  .lumen-input-group-addon {
    display: flex;
    flex: none;
    align-items: center;
    gap: 7px;
    color: var(--gc-icon);
    font: 500 13px var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    cursor: text;
    user-select: none;
  }
  .lumen-input-group-addon[data-align="inline-start"] {
    order: -1;
    padding-left: 12px;
  }
  .lumen-input-group-addon[data-align="inline-end"] {
    order: 99;
    padding: 0 12px 0 4px;
  }
  .lumen-input-group-addon[data-align="inline-start"]:has(>.lumen-input-group-text) {
    padding: 0 10px 0 12px;
    background: var(--gc-preBg);
    box-shadow: inset -1px 0 0 var(--gc-hair);
    border-radius: calc(12px * var(--lumen-radius-k, 1)) 0 0 calc(12px * var(--lumen-radius-k, 1));
  }
  .lumen-input-group-addon[data-align="inline-start"]>.lumen-input-group-text {
    font: 400 12.5px var(--lumen-font-mono, var(--font-mono, var(--font-geist-mono, 'Geist Mono'))), ui-monospace, monospace;
    color: var(--gc-sec);
    transition: color var(--gc-t-c) ease;
  }
  .lumen-input-group:focus-within .lumen-input-group-addon[data-align="inline-start"]>.lumen-input-group-text {
    color: var(--gc-m72);
  }
  .lumen-input-group-addon[data-align="block-start"] {
    order: -1;
    width: 100%;
    padding: 10px 14px 0;
  }
  .lumen-input-group-addon[data-align="block-end"] {
    order: 99;
    width: 100%;
    padding: 0 14px 10px;
  }
  .lumen-input-group-addon[data-align^="inline"]:has(>.lumen-button) {
    padding-inline-end: 6px;
  }
  .lumen-input-group-addon[data-align="block-end"]:has(>.lumen-button) {
    padding-bottom: 10px;
    gap: 14px;
  }
  .lumen-input-group-text {
    display: flex;
    align-items: center;
    gap: 7px;
    font: 400 12.5px var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    color: var(--gc-sec);
    white-space: nowrap;
  }
  .lumen-input-group-addon>[data-slot="kbd"],.lumen-input-group-text>[data-slot="kbd"] {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 20px;
    height: 20px;
    padding: 0 6px;
    border-radius: calc(6px * var(--lumen-radius-k, 1));
    background: var(--gc-kbd);
    font: 500 11px/1 var(--lumen-font-mono, var(--font-mono, var(--font-geist-mono, 'Geist Mono'))), ui-monospace, monospace;
    color: var(--gc-m70);
  }
  .lumen-input-group-status {
    display: inline-flex;
    align-items: center;
    font-size: 14px;
  }
  .lumen-input-group-status[data-status="checking"] {
    gap: 8px;
    color: var(--gc-acc-ui);
    font-size: 15px;
  }
  .lumen-input-group-status[data-status="ok"] {
    gap: 6px;
    color: var(--gc-good-ui);
  }
  .lumen-input-group-status[data-status="error"] {
    color: var(--gc-bad);
  }
  .lumen-input-group-word {
    font: 500 10px var(--lumen-font-mono, var(--font-mono, var(--font-geist-mono, 'Geist Mono'))), ui-monospace, monospace;
    letter-spacing: .1em;
    text-transform: uppercase;
  }
  @container lumen-input-group (max-width: 320px) {
    .lumen-input-group-word {
      display: none;
    }
  }
  .lumen-input-group-count {
    display: flex;
    flex: 1;
    align-items: center;
    gap: 10px;
    min-width: 0;
  }
  .lumen-input-group-cells {
    display: flex;
    flex: 1;
    gap: 2px;
    min-width: 0;
  }
  .lumen-input-group-cells>span {
    flex: 1;
    height: 4px;
    border-radius: calc(1.5px * var(--lumen-radius-k, 1));
    background: var(--gc-cellOff);
    transition: background var(--gc-t-c) ease;
  }
  .lumen-input-group-cells>span[data-t="ok"] {
    background: var(--gc-acc);
  }
  .lumen-input-group-cells>span[data-t="near"] {
    background: var(--gc-warn);
  }
  .lumen-input-group-cells>span[data-t="over"] {
    background: var(--gc-bad);
  }
  .lumen-input-group-number {
    font: 500 11px var(--lumen-font-mono, var(--font-mono, var(--font-geist-mono, 'Geist Mono'))), ui-monospace, monospace;
    font-variant-numeric: tabular-nums;
    color: var(--gc-sec);
    white-space: nowrap;
    transition: color var(--gc-t-c) ease;
  }
  .lumen-input-group-number[data-t="near"] {
    color: var(--gc-warn-ui);
  }
  .lumen-input-group-number[data-t="over"] {
    color: var(--gc-bad-ui);
  }
  @media (max-width: 699px) {
    .lumen-input {
      height: 44px;
      --lf-fs: 16px;
    }
    .lumen-textarea {
      --lf-fs: 16px;
    }
    .lumen-input-group:not(:has(>.lumen-textarea,>[data-align^="block"])) {
      height: 44px;
    }
    .lumen-input-group:has(>[data-align^="block"])>.lumen-input {
      height: 44px;
    }
  }
}
```

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.
