a text field: a sunken well, red when wrong, plain or a pill of glass
npx shadcn add @ag/inputAlready installed? Preview an update
npx shadcn add @ag/input --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-14
InputGrouptakes aprefix: text the value continues (github.com/,@), joined to it with no gap, so the two read as one address.startstays for an icon, set a little apart. A field that showed a prefix throughstartshowed a gap inside the address; move that text toprefix.
shelf controls
uses material, setting-context
used by command, components, search, chip-input, people-picker, country-picker
the demo above, verbatim
"use client"
import { Search, X } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Input, InputGroup } from "@/registry/base-nova/ui/input"
import { Kbd } from "@/registry/base-nova/ui/kbd"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Setting } from "@/registry/base-nova/ui/setting"
// #region fields
function Fields() {
const [q, setQ] = React.useState("")
const [email, setEmail] = React.useState("adrian@")
return (
<div className="grid w-full max-w-2xl items-center gap-3 sm:grid-cols-2">
<Input placeholder="a name" />
<InputGroup
placeholder="search the library"
value={q}
onChange={(e) => setQ(e.target.value)}
start={<Search />}
end={
q ? (
<button type="button" aria-label="clear" onClick={() => setQ("")}>
<X />
</button>
) : (
<Kbd keys="mod+k" />
)
}
/>
<InputGroup placeholder="250" end="ms" size="sm" />
<InputGroup
aria-label="github username"
prefix="github.com/"
defaultValue="adriangalilea"
/>
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!/^\S+@\S+\.\S+$/.test(email)}
size="lg"
/>
<Input placeholder="disabled" disabled />
</div>
)
}
// #endregion
// #region inherited
/** Text that falls back to the workspace's: empty, its placeholder the value that
* applies, drawn as a value; typing sets it here, emptying it (or ×) clears it
* back. */
function Inherited() {
const [host, setHost] = React.useState<string | undefined>(undefined)
const [region, setRegion] = React.useState<string | undefined>("eu-north")
return (
<div className="flex w-full max-w-72 flex-col gap-3">
<Setting
label="host"
value={host}
fallback={{ value: "build.local", from: "the workspace" }}
onValueChange={setHost}
className="w-full"
>
{(s) => (
<Input
aria-label="host"
value={s.value}
onChange={(e) => s.pin(e.target.value)}
className="flex-1"
/>
)}
</Setting>
<Setting
label="region"
value={region}
fallback={{ value: "eu-west", from: "the workspace" }}
onValueChange={setRegion}
className="w-full"
>
{(s) => (
<Input
aria-label="region"
value={s.value}
onChange={(e) => s.pin(e.target.value)}
className="flex-1"
/>
)}
</Setting>
</div>
)
}
// #endregion
export default function InputDemo() {
return (
<div className="space-y-8">
<Sample
name="fields"
glance="wide"
title="a field that reads as one"
description="a sunken well that brightens under the hand and on focus, red when the value is wrong. alone, or with an icon, a unit, a key or a clear button inside the well. three heights, the button's."
with="fields"
>
<Fields />
</Sample>
<Sample
name="glass"
title="the same fields, in glass"
description="MaterialProvider turns every field inside it to glass: a pill like the glass button, its rim lit while you type, red light on it when the value is wrong."
with="fields"
>
<MaterialProvider material="glass">
<div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6">
<Fields />
</div>
</MaterialProvider>
</Sample>
<Sample
name="inherited"
title="following a default"
description="host follows the workspace: the field is empty and what applies stands in it as a value (dashed well), not a hint. region is set here."
with="inherited"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="p-6">
<Inherited />
</div>
<MaterialProvider material="glass" scheme="dark">
<div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6">
<Inherited />
</div>
</MaterialProvider>
</div>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import type * as React from "react"
import { cn } from "@/lib/utils"
import {
type Material,
MaterialProvider,
type Scheme,
useMaterial,
useSurfaceScheme,
type WornScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import {
GHOST_EDGE,
GLASS_GHOST,
useSetting,
} from "@/registry/base-nova/ui/setting-context"
// A text field that looks like one: a sunken well (a faint fill and a hairline ring
// inset), the ring brightening under the hand and clear on focus, red when the value
// is wrong (`aria-invalid`). At the button's heights, so a field and its button sit
// as one row. `Input` alone, or inside `InputGroup` with things before and after it
// (an icon, a unit, a key, a clear button) that stay inside the well.
//
// `material="glass"`: a well of glass, a pill like the glass button beside it, its
// rim catching more light under the hand and more again while you type in it, red
// light on the rim when the value is wrong. The glass is the field's wrapper, never
// the <input>: an input draws no ::before, where the glass's rim lives.
//
// Inside a `Setting` whose value is inherited, the field is empty and its
// placeholder is the value that applies, drawn as a value (a ghost: the well
// dashed, the words a step back), not a hint; emptied, it clears what is set here.
export type InputSize = "sm" | "md" | "lg"
const HEIGHT: Record<InputSize, string> = {
sm: "h-7 text-[12px]",
md: "h-8 text-[13px]",
lg: "h-10 text-sm",
}
const PAD: Record<InputSize, string> = {
sm: "px-2",
md: "px-2.5",
lg: "px-3",
}
/** A pill's ends are rounder: the glass field's text stands further in. Its radius
* is half its height. */
const GLASS_PAD: Record<InputSize, string> = {
sm: "px-3 [--glass-radius:14px]",
md: "px-3.5 [--glass-radius:16px]",
lg: "px-4 [--glass-radius:20px]",
}
/** The well: everything that makes a box read as a field, on whatever holds it (a
* field of chips wears it too, chip-input). */
export const WELL =
"rounded-md bg-foreground/[0.04] ring-1 ring-foreground/12 ring-inset transition-[box-shadow,background-color] hover:ring-foreground/22 has-[input:focus-visible]:bg-foreground/[0.06] has-[input:focus-visible]:ring-foreground/40 has-[input[aria-invalid=true]]:ring-tone-bad/70 has-[input:disabled]:opacity-50"
/** The well in glass. A text field's focus is its rim catching light whichever way
* focus came (you type where it is, so a click is as much focus as a Tab); wrong, the
* rim takes the registry's red. */
export const GLASS_WELL =
"hover:[--glass-rim:1.3]! focus-within:[--glass-rim:1.7]! has-[input[aria-invalid=true]]:[--glass-stain:var(--tone-bad)]! has-[input[aria-invalid=true]]:[--glass-rim-color:var(--tone-bad)]! has-[input[aria-invalid=true]]:[--glass-rim:2.2]! has-[input:disabled]:opacity-50"
/** The bare text, for inside a well. */
const FIELD =
"min-w-0 flex-1 bg-transparent outline-none disabled:cursor-not-allowed"
/** Inherited (inside a `Setting`, nothing set here): the field is empty and its
* placeholder IS the value that applies, so it reads as a value, not a hint: in
* the words' ink a step back (a hint's is fainter), the well unfilled and dashed;
* glass, the material's ghost. */
const GHOST: Record<Material, string> = {
plain: cn(
"relative bg-transparent ring-0 [&_input]:placeholder:text-foreground/60",
GHOST_EDGE,
),
glass: cn(GLASS_GHOST, "[&_input]:placeholder:text-current/60"),
}
/** What a setting around the field changes: empty and with the fallback as its
* placeholder while inherited, emptied it clears, read-only while locked,
* described by where its value comes from. */
function useSettingInput({
value,
placeholder,
readOnly,
onChange,
"aria-describedby": describedBy,
}: InputProps) {
const setting = useSetting()
if (!setting) return { ghost: false, props: {} }
const ghost = setting.inherited
return {
ghost,
props: {
value: ghost ? "" : value,
placeholder: setting.fallback
? String(setting.fallback.value)
: placeholder,
readOnly: readOnly || setting.readOnly,
"aria-describedby":
[describedBy, setting.says].filter(Boolean).join(" ") || undefined,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value === "" && setting.clear) setting.clear()
else onChange?.(e)
},
} satisfies Partial<InputProps>,
}
}
export type InputProps = Omit<React.ComponentPropsWithRef<"input">, "size"> & {
size?: InputSize
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
}
/** The wrapper's face in either material, in its scheme. */
function well(m: Material, size: InputSize, s: WornScheme) {
return m === "glass"
? [
schemeOf(s),
glassVariants({ shape: "surface" }),
GLASS_WELL,
GLASS_PAD[size],
"[&_input]:placeholder:text-current/40",
]
: [schemeOf(s), WELL, PAD[size], "[&_input]:placeholder:text-foreground/35"]
}
/** A field on its own: the well and the text are one element. */
export function Input({
size = "md",
material,
scheme,
className,
...props
}: InputProps) {
const m = useMaterial(material)
// Over a picture the well is read by the part of it under the well; `ref` is the
// <input>'s.
const [s, here] = useSurfaceScheme<HTMLSpanElement>(scheme)
const setting = useSettingInput(props)
return (
<span
ref={here}
data-slot="input"
data-size={size}
data-material={m}
data-inherited={setting.ghost ? "" : undefined}
className={cn(
"flex items-center",
HEIGHT[size],
well(m, size, s),
setting.ghost && GHOST[m],
className,
)}
>
<input className={FIELD} {...props} {...setting.props} />
</span>
)
}
/** A field with things in the well beside the text: `start` before it (an icon), `end`
* after it (a unit, a key, a clear button), set a little apart; `prefix`, text the
* value continues (`github.com/`, `@`), joined to it with no gap at all, so the two
* read as one address. They sit quiet, in the field's height. */
export function InputGroup({
size = "md",
material,
scheme,
start,
prefix,
end,
className,
...props
}: Omit<InputProps, "prefix"> & {
start?: React.ReactNode
/** Text the value continues, joined to it: never a gap between them. */
prefix?: React.ReactNode
end?: React.ReactNode
}) {
const m = useMaterial(material)
const [s, here] = useSurfaceScheme<HTMLSpanElement>(scheme)
const setting = useSettingInput(props)
const quiet = m === "glass" ? "text-current/55" : "text-foreground/45"
return (
<span
ref={here}
data-slot="input-group"
data-size={size}
data-material={m}
data-inherited={setting.ghost ? "" : undefined}
className={cn(
"flex items-center gap-1.5",
HEIGHT[size],
well(m, size, s),
setting.ghost && GHOST[m],
"[&_svg]:size-3.5 [&_svg]:shrink-0",
className,
)}
>
{/* What sits in the well stands on the well: it wears the well's scheme. */}
<MaterialProvider scheme={s}>
{start && (
<span
data-slot="input-start"
className={cn("flex shrink-0 items-center", quiet)}
>
{start}
</span>
)}
{prefix ? (
// The prefix and the value in one run of text: the group's gap is
// between things beside the text, never inside it.
<span className="flex min-w-0 flex-1 items-center">
<span
data-slot="input-prefix"
className={cn("shrink-0 whitespace-pre", quiet)}
>
{prefix}
</span>
<input className={FIELD} {...props} {...setting.props} />
</span>
) : (
<input className={FIELD} {...props} {...setting.props} />
)}
{end && (
<span
data-slot="input-end"
className={cn("flex shrink-0 items-center", quiet)}
>
{end}
</span>
)}
</MaterialProvider>
</span>
)
}