Input
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";
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 { Field, Icon, Input } from "@stridge/noctis";
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>
);
}
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";
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, Input } from "@stridge/noctis";
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, Input } from "@stridge/noctis";
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 { Field, Icon, Input } from "@stridge/noctis";
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, Input } from "@stridge/noctis";
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, Input, Kbd } from "@stridge/noctis";
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, Input } from "@stridge/noctis";
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, Input } from "@stridge/noctis";
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";
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";
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";
export default function InputFile() {
return (
<Input.Root className="w-full max-w-sm">
<Input.Control type="file" aria-label="Attachment" />
</Input.Root>
);
}
Scoped radius
Noctis fields are pill by default — the shell rounds fully — but RadiusScope is the escape hatch: wrap a region and every primitive inside re-rounds (square it for a dense dashboard, soften it for a settings panel). The border, rest shadow, and segmented addons all follow the shell's radius, so the whole field rounds together.
"use client";
import { Input, RadiusScope } from "@stridge/noctis";
export default function InputScopedRadius() {
return (
<RadiusScope radius="pill">
<Input.Root className="max-w-xs">
<Input.Control aria-label="Search" placeholder="Search…" />
</Input.Root>
</RadiusScope>
);
}
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.