The text field — an editable control wearing the field surface, border, and a calm, ring-less focus treatment. The canonical Noctis field: validity and disabled flow from the control, with leading and trailing adornments, interactive in-field actions, a live character count, segmented addons, two sizes, and read-only.
Basic
Compose Input.Root — the shell that paints the field chrome and owns the size — around an Input.Control, the Base UI input. Focusing the control lights the whole shell; the border shifts to the accent focus role, no surrounding ring. A subtle rest shadow gives the field its seated, slightly-inset feel.
"use client";
import { Input } from "@stridge/noctis/input";
export default function InputBasic() {
return (
<Input.Root className="max-w-xs">
<Input.Control aria-label="Full name" placeholder="Ada Lovelace" />
</Input.Root>
);
}
With a field
The canonical usage — and the pattern the whole field family copies. Wrap the input in a Noctis Field.Root with a Field.Label, Field.Description, and Field.Error: the label auto-associates, the description and error join the control through aria-describedby, and aria-invalid flips from validation — all without setting a single state prop on the shell.
We only use it to sign you in.
"use client";
import { Icon } from "@stridge/noctis";
import { Field } from "@stridge/noctis/field";
import { Input } from "@stridge/noctis/input";
import { Mail } from "lucide-react";
export default function InputWithField() {
return (
<Field.Root className="w-full max-w-sm">
<Field.Label>Email</Field.Label>
<Input.Root>
<Input.Adornment side="start">
<Icon icon={Mail} />
</Input.Adornment>
<Input.Control type="email" required placeholder="you@example.com" />
</Input.Root>
<Field.Description>We only use it to sign you in.</Field.Description>
<Field.Error match="valueMissing">Enter your email address.</Field.Error>
<Field.Error match="typeMismatch">That does not look like an email address.</Field.Error>
</Field.Root>
);
}
In a dialog
The field works the same inside a focus-managed overlay as it does on the page: clicking the shell — the padding around the text, the adornment columns, anywhere that isn't an interactive child — focuses the control. The click-to-focus guard is scoped to the shell, so the dialog's own focus container doesn't swallow it.
"use client";
import { Icon } from "@stridge/noctis";
import { Button } from "@stridge/noctis/button";
import { Dialog } from "@stridge/noctis/dialog";
import { Field } from "@stridge/noctis/field";
import { Input } from "@stridge/noctis/input";
import { Link2 } from "lucide-react";
export default function InputInDialog() {
return (
<Dialog.Root>
<Dialog.Trigger render={<Button variant="outline">Rename workspace</Button>} />
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Rename workspace</Dialog.Title>
<Dialog.Description>
Click anywhere in the field — the padding around the text counts — to focus it.
</Dialog.Description>
<Dialog.CloseButton />
</Dialog.Header>
<Dialog.Body>
<Field.Root>
<Field.Label>Workspace URL</Field.Label>
<Input.Root>
<Input.Adornment side="start">
<Icon icon={Link2} />
</Input.Adornment>
<Input.Control aria-label="Workspace URL" defaultValue="acme" />
<Input.Adornment side="end" variant="segment">
.stridge.app
</Input.Adornment>
</Input.Root>
</Field.Root>
</Dialog.Body>
<Dialog.Footer>
<Dialog.Close render={<Button variant="secondary">Cancel</Button>} />
<Dialog.Close render={<Button variant="primary">Save</Button>} />
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
);
}
Sizes
Two heights — medium (the default) and large — sharing one field surface and type rhythm, both riding the density knob. The size lives on Input.Root; the control, adornments, actions, and in-field glyphs inherit its metrics.
"use client";
import { Input } from "@stridge/noctis/input";
export default function InputSizes() {
return (
<div className="flex max-w-xs flex-col gap-3">
<Input.Root size="md">
<Input.Control aria-label="Medium field" placeholder="Medium" />
</Input.Root>
<Input.Root size="lg">
<Input.Control aria-label="Large field" placeholder="Large" />
</Input.Root>
</div>
);
}
Adornments
Flank the control with a leading or trailing adornment — a search glyph, a currency mark, a unit. Set side to start or end; the shell's row orders them logically, so they mirror under RTL, and their icons take the field's size automatically.
"use client";
import { Icon } from "@stridge/noctis";
import { Input } from "@stridge/noctis/input";
import { Search } from "lucide-react";
export default function InputAdornments() {
return (
<div className="flex max-w-xs flex-col gap-3">
<Input.Root>
<Input.Adornment side="start">
<Icon icon={Search} size="sm" />
</Input.Adornment>
<Input.Control aria-label="Search" placeholder="Search…" />
</Input.Root>
<Input.Root>
<Input.Adornment side="start">$</Input.Adornment>
<Input.Control aria-label="Amount" placeholder="0.00" inputMode="decimal" />
<Input.Adornment side="end">USD</Input.Adornment>
</Input.Root>
</div>
);
}
Addon groups
Set an adornment's variant to segment for a bordered cell flush to the field edge — the prefix/suffix idiom (https:// ▸ field ▸ .com). Pair the control with a trailing Input.Action for an inline button group.
"use client";
import { Icon } from "@stridge/noctis";
import { Input } from "@stridge/noctis/input";
import { Copy } from "lucide-react";
export default function InputAddonGroup() {
return (
<div className="flex w-full max-w-sm flex-col gap-3">
{/* Segmented prefix/suffix — bordered cells flush to the field edge. */}
<Input.Root>
<Input.Adornment side="start" variant="segment">
https://
</Input.Adornment>
<Input.Control aria-label="Site" defaultValue="acme" />
<Input.Adornment side="end" variant="segment">
.com
</Input.Adornment>
</Input.Root>
{/* A trailing action paired with a segmented unit. */}
<Input.Root>
<Input.Control aria-label="API key" defaultValue="sk_live_8f2c…" readOnly />
<Input.Action aria-label="Copy">
<Icon icon={Copy} />
</Input.Action>
</Input.Root>
</div>
);
}
Clearable
Input.Action is an interactive in-field button with the system's ghost-control discipline — a neutral hover, its own focus ring, and a compact icon-button footprint tucked against the field edge. Give a clear button data-reveal="filled" and it appears only once the field holds a value, driven purely by CSS. Pressing Escape on a search-style field clears it too.
"use client";
import { Icon } from "@stridge/noctis";
import { Field } from "@stridge/noctis/field";
import { Input } from "@stridge/noctis/input";
import { X } from "lucide-react";
import { type KeyboardEvent, useRef, useState } from "react";
export default function InputClearable() {
const [value, setValue] = useState("Noctis");
const ref = useRef<HTMLInputElement>(null);
function clear() {
setValue("");
ref.current?.focus();
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
// Search-field convention: Escape clears the value but keeps focus.
if (event.key === "Escape" && value) {
event.preventDefault();
clear();
}
}
// The Field stamps data-filled on the control while it holds a value; the clear action's
// data-reveal="filled" hides it on an empty field purely through CSS :has().
return (
<Field.Root className="w-full max-w-sm">
<Input.Root>
<Input.Control
ref={ref}
aria-label="Project name"
placeholder="Project name"
value={value}
onValueChange={setValue}
onKeyDown={handleKeyDown}
/>
<Input.Action data-reveal="filled" aria-label="Clear" onClick={clear}>
<Icon icon={X} />
</Input.Action>
</Input.Root>
</Field.Root>
);
}
Password reveal
A reveal toggle is an Input.Action that keeps a static accessible name and flips aria-pressed (not its label), swapping the eye glyph and the control's type. Screen readers hear the pressed state change rather than a name shifting under them.
"use client";
import { Icon } from "@stridge/noctis";
import { Input } from "@stridge/noctis/input";
import { Eye, EyeOff } from "lucide-react";
import { useState } from "react";
export default function InputPassword() {
const [revealed, setRevealed] = useState(false);
// The reveal toggle keeps a static accessible name and flips aria-pressed (not its label), so a
// screen reader hears the pressed state rather than a name changing under it.
return (
<Input.Root className="w-full max-w-sm">
<Input.Control
aria-label="Password"
type={revealed ? "text" : "password"}
defaultValue="correct-horse"
autoComplete="current-password"
/>
<Input.Action aria-label="Show password" aria-pressed={revealed} onClick={() => setRevealed((shown) => !shown)}>
<Icon icon={revealed ? EyeOff : Eye} />
</Input.Action>
</Input.Root>
);
}
Search
A search-typed field with a leading glyph and a trailing shortcut chip that cross-fades from ⌘K to Esc once the field is dirty. Escape clears the value and keeps focus.
"use client";
import { Icon } from "@stridge/noctis";
import { Input } from "@stridge/noctis/input";
import { Kbd } from "@stridge/noctis/kbd";
import { Search } from "lucide-react";
import { type KeyboardEvent, useState } from "react";
export default function InputSearch() {
const [value, setValue] = useState("");
const dirty = value.length > 0;
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "Escape" && dirty) {
event.preventDefault();
setValue("");
}
}
// The trailing chip cross-fades from the ⌘K shortcut to Esc once the field is dirty — both chips are
// stacked in one grid cell and only their opacity changes, so the swap animates (and stills under
// reduced motion).
return (
<Input.Root className="w-full max-w-sm">
<Input.Adornment side="start">
<Icon icon={Search} />
</Input.Adornment>
<Input.Control
type="search"
aria-label="Search"
placeholder="Search…"
value={value}
onValueChange={setValue}
onKeyDown={handleKeyDown}
/>
<Input.Adornment side="end">
<span className="grid">
<Kbd
keys="Mod+K"
className={`col-start-1 row-start-1 transition-opacity motion-reduce:transition-none ${dirty ? "opacity-0" : "opacity-100"}`}
/>
<Kbd
keys="Escape"
className={`col-start-1 row-start-1 transition-opacity motion-reduce:transition-none ${dirty ? "opacity-100" : "opacity-0"}`}
/>
</span>
</Input.Adornment>
</Input.Root>
);
}
Character count
Input.Count is a quiet trailing readout of the value length against a max. It escalates from muted to the warning role as the value nears the limit, then the danger role past it, and announces the remaining count through a polite, atomic live region — empty on first paint, so typing isn't narrated character by character. Point the control's aria-describedby at it to join the count to the field.
"use client";
import { Field } from "@stridge/noctis/field";
import { Input } from "@stridge/noctis/input";
import { useId, useState } from "react";
const MAX = 80;
export default function InputCharCount() {
const [value, setValue] = useState("A short bio that is creeping toward the limit.");
const countId = useId();
// Pointing the control's aria-describedby at the count joins it to the field's description, while the
// count's own polite live region announces the remaining characters as the value nears the limit.
return (
<Field.Root className="w-full max-w-sm">
<Input.Root>
<Input.Control
aria-label="Bio"
aria-describedby={countId}
placeholder="Tell us about yourself"
value={value}
onValueChange={setValue}
/>
<Input.Count id={countId} value={value} max={MAX} />
</Input.Root>
</Field.Root>
);
}
Invalid
Validity flows from the control: set aria-invalid (or let a Noctis Field.Root set it from validation) and the shell draws the danger border and holds it even when focused. There is no invalid prop to mirror on the shell — one source of truth. The shell does keep an invalid escape hatch for shell-only styling without a control state.
"use client";
import { Field } from "@stridge/noctis/field";
import { Input } from "@stridge/noctis/input";
export default function InputInvalid() {
// No `invalid` on the shell: the danger border flows from the control's aria-invalid (shown here
// statically; a Base UI Field sets it from validation), and the error joins the field automatically.
return (
<Field.Root className="w-full max-w-sm">
<Field.Label>Email</Field.Label>
<Input.Root>
<Input.Control type="email" defaultValue="not-an-email" aria-invalid />
</Input.Root>
<Field.Error match>Enter a valid email address.</Field.Error>
</Field.Root>
);
}
Read-only
A read-only field reads as quiet-but-present: a calm border, no accent focus fill, and selectable, focusable text — distinct from the dimmed, not-allowed disabled field beside it. It flows from the control's readOnly.
"use client";
import { Input } from "@stridge/noctis/input";
export default function InputReadOnly() {
return (
<div className="flex w-full max-w-sm flex-col gap-3">
{/* Read-only: quiet border, no focus fill, but the text stays selectable and the field focusable. */}
<Input.Root>
<Input.Control aria-label="Read-only field" readOnly defaultValue="read-only@example.com" />
</Input.Root>
{/* Disabled: dimmed and not-allowed — visibly distinct from read-only. */}
<Input.Root>
<Input.Control aria-label="Disabled field" disabled defaultValue="disabled@example.com" />
</Input.Root>
</div>
);
}
Disabled
A disabled field dims to the disabled opacity and blocks the text cursor. Disable the control alone — the shell reads it through :has() and dims to match, with no need to set disabled on the shell too.
"use client";
import { Input } from "@stridge/noctis/input";
export default function InputDisabled() {
// Disable the control alone — the shell reads it through :has() and dims to match. No need to set
// disabled on the root too.
return (
<Input.Root className="max-w-xs">
<Input.Control aria-label="Field" placeholder="Unavailable" disabled />
</Input.Root>
);
}
File
A file input takes a compact, quiet selector button matched to the field, with the chosen filename in the muted role beside it.
"use client";
import { Input } from "@stridge/noctis/input";
export default function InputFile() {
return (
<Input.Root className="w-full max-w-sm">
<Input.Control type="file" aria-label="Attachment" />
</Input.Root>
);
}
Theme scope
ThemeScope re-tunes a whole region's shape — corner radius, density, and type scale — through the cascade, with no per-component prop and without touching the app-wide theme. Tweak the knobs below and watch every input inside the scope retune together. See Customization for nesting, reset, and the portal caveat.
"use client";
import { DENSITY_PRESETS, FONT_SCALE_PRESETS, RADIUS_PRESETS, ThemeScope } from "@stridge/noctis";
import { Input } from "@stridge/noctis/input";
import { Select } from "@stridge/noctis/select";
import { useState } from "react";
/** Title-case a preset key for its select label. */
const label = (key: string) => key.charAt(0).toUpperCase() + key.slice(1);
/** One labelled preset picker — its options are a shipped `ThemeScope` preset map, so the playground's
* vocab is exactly what the primitive accepts. */
function Knob({
name,
presets,
value,
onChange,
}: {
name: string;
presets: Record<string, number>;
value: string;
onChange: (value: string) => void;
}) {
const keys = Object.keys(presets);
const items = Object.fromEntries(keys.map((key) => [key, label(key)]));
return (
<label className="flex flex-col gap-1.5">
<span className="text-mini font-medium text-subtle">{name}</span>
<Select.Root items={items} value={value} onValueChange={(next) => onChange(String(next))}>
<Select.Trigger aria-label={name} className="w-40">
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
{keys.map((key) => (
<Select.Item key={key} value={key}>
{label(key)}
</Select.Item>
))}
</Select.Popup>
</Select.Root>
</label>
);
}
/**
* A live `ThemeScope` playground: the three seed knobs (radius, density, type scale) drive a scope
* wrapping an input cluster, so every control inside re-tunes together — corners, spacing, and text
* size — with no per-input prop and without touching the app-wide theme.
*/
export default function InputThemeScope() {
const [radius, setRadius] = useState<keyof typeof RADIUS_PRESETS>("pill");
const [density, setDensity] = useState<keyof typeof DENSITY_PRESETS>("default");
const [fontScale, setFontScale] = useState<keyof typeof FONT_SCALE_PRESETS>("default");
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap gap-3">
<Knob name="Radius" presets={RADIUS_PRESETS} value={radius} onChange={(v) => setRadius(v as typeof radius)} />
<Knob
name="Density"
presets={DENSITY_PRESETS}
value={density}
onChange={(v) => setDensity(v as typeof density)}
/>
<Knob
name="Font scale"
presets={FONT_SCALE_PRESETS}
value={fontScale}
onChange={(v) => setFontScale(v as typeof fontScale)}
/>
</div>
<div className="rounded-md border border-dashed border-border p-4">
<ThemeScope radius={radius} density={density} fontScale={fontScale}>
<div className="flex flex-wrap items-center gap-3">
<Input.Root className="max-w-xs">
<Input.Control aria-label="Full name" placeholder="Ada Lovelace" />
</Input.Root>
<Input.Root className="max-w-xs">
<Input.Control aria-label="Email" placeholder="ada@example.com" />
</Input.Root>
</div>
</ThemeScope>
</div>
</div>
);
}
Accessibility
- State flows from the control. The shell reads the control's
:disabled,aria-invalid, andreadonly(and Base UI/Fielddata-*) through:has(), so validity and disabled have one source of truth — no double-settinginvalid+aria-invalidordisabledon both. - Pair it with a
Field. A NoctisField.Rootauto-associates the label, joins the description and error througharia-describedby, and setsaria-invalidfrom validation. Validate on blur; write placeholders as example values, not instructions. - Calm, visible focus. Focus shifts the border to the accent focus role (no surrounding ring) — clearly visible without the aggressive glow, on both keyboard and pointer focus the way a text field naturally highlights when active.
- Actions are real, labelled buttons.
Input.Actionrenders a<button type="button">; an icon-only action needs anaria-label(or aVisuallyHiddenchild). The reveal toggle keeps a static name and flipsaria-pressed. Tab reaches the control before trailing actions (DOM order). - The count announces politely.
Input.Countexposes the remaining characters through anaria-live="polite",aria-atomicregion that starts empty, and joins the control viaaria-describedby. - Logical & RTL-correct. All geometry is logical (
padding-inline,gap, logical borders and insets), so adornments, actions, segments, and the count all mirror under RTL by construction.
Anatomy
Input.Root— the field shell. Paints the surface, border, rest shadow, and the focus border; owns thesize. Reads the control's state through:has(); keepsinvalid/disabledas additive shell-only overrides. Clicking its padding focuses the control.Input.Control— the editable Base UI input. Stays unstyled so the value, caret, and selection inherit the field's type; itsdisabled/readOnly/aria-invaliddrive the shell.Input.Adornment— a leading (start) or trailing (end) icon, unit, or affordance. The defaultquietvariant is transparent;segmentis a bordered cell flush to the field edge.Input.Action— an interactive in-field button (clear, reveal, shortcut) with ghost-control discipline, sized to the field height. Adddata-reveal="filled"to show it only while the field holds a value.Input.Count— a live character-count readout that escalates muted → warning → danger near and past the limit.
The parts stamp the data-slot values noctis-input, noctis-input-control, noctis-input-adornment, noctis-input-action, and noctis-input-count. Target them with the state attributes — data-size on the shell, data-side/data-variant on an adornment, the control's data-filled/data-dirty/data-invalid/data-readonly — to style, say, a clear button that only appears once there's a value.
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every input in that region retunes — e.g. .compact { --noctis-input-height: 2rem; } shortens every field beneath it. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Input.Control extends the Base UI Input props, so onValueChange, defaultValue, and the native input attributes pass straight through; expand a row for the full type and description.