forms
Field
The label, description and error wrapper every control shares — wiring up ids and aria attributes for you.
Examples
Number
Seven takes on the same stepper, all the same compact size: a pill, a card, a bare chip pair, a solid bar, stacked chevrons, plain text buttons, and dashed outlines.
A pill stepper — buttons attached to the field itself.
Round buttons in a card instead of a bare spinner.
No box at all — just two soft, color-coded chips.
Solid, blocky buttons — built for a thumb, not a cursor.
Stacked chevrons replace the left/right pair entirely.
No buttons at all — just underlined text doing the work.
Dashed all the way through — button, badge and button alike.
Percentage
The number field and the range input underneath share one value — drag either, both move.
Drag the slider or type a number — both stay in sync.
Phone
Two takes on the same field: plain digit formatting, then a calling-code dropdown stacked underneath.
Formatted as you type — only digits are ever stored.
245 countries, formatted with `libphonenumber-js` — stored as +1 ….
Verify
Four channels, four designs: an attached send button, a two-step email with OTP boxes, a countdown ring around a call button, and a push notification with no input at all.
Enter 10 digits to unlock the send button.
A valid address unlocks the send button.
The ring around the button is the countdown.
No input at all — the device is already known.
URL
Seven takes on a link: hostname parsing, a subdomain slug, a fixed https:// chip, a live preview card, a Go button that fetches the real favicon, a paste-from-clipboard button, and a deterministic short code.
Include the protocol — https://…
Live at yourname.folio.dev
The protocol is a fixed chip, not something you type.
Paste a link to preview it.
Press Go to fetch the site's favicon — a real request, not a fake timer.
Click the icon to paste instead of typing.
A 6-character code, deterministically derived from the URL.
Search
Twenty takes on searching a fixed list — the practical, the bold (frosted glass, a gradient ring, neon, a dark spotlight), and the animated (a typewriter placeholder, a pulsing ring, a sliding underline, shimmering Skeleton rows, a staggered reveal).
Dark chrome and a shortcut chip — built to float over a page.
The × only exists once there's something to clear.
A real 500ms debounce, not an instant filter.
Empty and focused reveals history instead of nothing.
One list, two sources — components and guides side by side.
Tap the mic and the field fills itself.
Starts as a button — becomes a field on click.
Arrow keys move the highlight, Enter picks it.
The badge counts every match, not just the five shown elsewhere.
The matched letters are bolded, not just the row.
Frosted glass over a live gradient, not a flat colour.
A gradient border and a gradient button — same two colours, twice.
Focus the field and the border lights up.
Oversized and dark — built to sit on top of everything else.
The placeholder types itself, one letter at a time.
A blurred glow breathes behind the whole field, always.
No box at all — a gradient bar grows from the centre on focus.
Real Skeleton rows, not a spinner, while the 700ms debounce runs.
Each chip scales in slightly after the last.
Date
Eight fully custom calendar popovers sharing one hook for positioning, focus-trapping and keyboard nav — a plain one, gradient, glass, neon, a boarding-pass tile, borderless minimal, a radial progress ring, and a countdown that leads with days remaining.
7 days from today — bookable up to 90 days out.
3 days out — the ring matches the selected day.
Frosted glass over a gradient — the trigger, not just a card.
Focus the trigger and the border lights up — dark in both themes.
The date itself is the icon — no separate calendar glyph.
No box, no fill — just a rule and a heavy shadow when it opens.
7% of the way through the window — the ring is a progress bar, not decoration.
The countdown is the headline — the calendar date is secondary.
Time
Eight takes on picking a time: a validated native input, a gradient ring, frosted glass, neon, a dark LCD tile with its own steppers, a 12-hour field with an AM/PM toggle, a small analogue dial beside an input, and a large dial that is the whole field.
2:00 AM — low-traffic hours run midnight to 5am.
9:30 AM — same gradient ring as the date pickers.
Frosted glass over a gradient — the trigger itself, not a card behind it.
Focus the field and the border lights up — dark in both themes.
An LCD tile with its own steppers — no native input in sight.
Silences notifications until 6:00 PM.
The hands move live — a real analogue readout, not a static icon.
Click the outer ring for the hour, the dashed inner ring for the minute.
Color
Six takes on picking a color: a live WCAG AA ratio, brand-preset swatches, a typed hex value synced to a hidden picker, a card that fades into the color itself, real "Aa" text sitting on the swatch, and a circular swatch with a pass/fail badge.
4.47:1 against white — text this color won't pass AA.
Six brand presets, or the native picker for anything else.
Type a hex code or click the swatch — both write the same value.
The card itself fades into the picked color — a live preview, not a swatch.
4.47:1 — neither side is a safe bet on this color.
The badge is the AA verdict — click the circle to change it.
File
Thirteen takes on picking a file — real drag-and-drop throughout, several with live thumbnail previews: a plain input, a custom-Button trigger, gradient, glass and neon dropzones, a drop-zone-that-becomes-a-thumbnail, a multi-file gallery, an always-visible camera badge on the avatar, and an upload ring around it.
PNG or SVG, up to 2MB.
A real Button, not the browser's own — same file input underneath.
Drop a file here
or click to browse
Real drag-and-drop, not just a styled label.
A real thumbnail, built from the file itself — not a filename.
A camera badge that's always visible, not one that only shows up on hover.
Drop a file here
or click to browse
The border turns into a gradient the moment a file crosses it.
A gradient progress bar that actually moves.
Picked becomes a removable row — no native text to fight with.
Drop an image to preview it here
or click to browse
Drop and preview in one motion — the zone becomes the thumbnail.
Drop a file here
or click to browse
Frosted glass over a gradient — the dropzone itself, not a card behind it.
Drop a file here
or click to browse
Dark in both themes — the border glows the moment a file crosses it.
0 files — each tile removable on its own.
Drop or click — the ring around it is upload progress.
Currency
Six takes on money: grouped digits, an oversized price tag with quick-adjust chips, a currency-code Select with a live symbol, a gradient card that doubles as a spend progress bar, a dark ticker readout, and an input linked to a slider.
Digits are grouped with thousands separators as you type.
The number is the whole design — everything else is secondary.
Pick a currency from the dropdown — the symbol updates with it.
64% of a $5,000 monthly limit.
The card itself is the progress bar — not a separate widget.
A ticker readout — dark in both themes, glows the moment you focus it.
Drag the slider or type a number — both stay in sync.
Own the source
Noksha ships as a package and as copy-paste source. Take the files and they are yours to change. These are read from the generated registry, so they are exactly what the library ships — never a paraphrase of it.
Self-contained apart from @noksha-ui/core. Or let the CLI do it: npx @noksha-ui/cli add field writes them, follows the same dependency graph, and fixes up the imports.
import { Slot } from '@noksha-ui/core';
import * as React from 'react';
import type {
FieldControlProps,
FieldLabelProps,
FieldProps,
FieldSize,
FieldTextProps,
} from './field.types.js';
import {
fieldDescriptionVariants,
fieldErrorVariants,
fieldLabelVariants,
fieldRequiredVariants,
fieldVariants,
} from './field.variants.js';
interface FieldContextValue {
id: string;
descriptionId: string;
errorId: string;
invalid: boolean;
required: boolean;
disabled: boolean;
size: FieldSize;
hasDescription: boolean;
hasError: boolean;
setHasDescription: (present: boolean) => void;
setHasError: (present: boolean) => void;
}
const FieldContext = React.createContext<FieldContextValue | null>(null);
/**
* Groups a label, a control and its helper text, and wires the aria between
* them.
*
* ```tsx
* <Field.Root invalid={!!errors.email} required>
* <Field.Label>Email</Field.Label>
* <Input name="email" />
* <Field.Description>We only use this for receipts.</Field.Description>
* <Field.Error>{errors.email?.message}</Field.Error>
* </Field.Root>
* ```
*
* Every control in the library reads this context, so the `id`,
* `aria-describedby`, `aria-invalid` and `required` wiring is done once here
* rather than by hand at each of the hundreds of call sites in an application —
* which is where it is normally got wrong.
*/
export const FieldRoot = React.forwardRef<HTMLDivElement, FieldProps>(function FieldRoot(
{
id,
invalid = false,
required = false,
disabled = false,
size = 'md',
orientation = 'vertical',
asChild = false,
className,
...rest
},
ref,
) {
const generatedId = React.useId();
const fieldId = id ?? `noksha-field-${generatedId}`;
// Which helper texts exist is discovered rather than declared, so
// `aria-describedby` never points at an id that is not in the document —
// a dangling reference makes the whole attribute be ignored.
const [hasDescription, setHasDescription] = React.useState(false);
const [hasError, setHasError] = React.useState(false);
const value = React.useMemo<FieldContextValue>(
() => ({
id: fieldId,
descriptionId: `${fieldId}-description`,
errorId: `${fieldId}-error`,
invalid,
required,
disabled,
size,
hasDescription,
hasError,
setHasDescription,
setHasError,
}),
[fieldId, invalid, required, disabled, size, hasDescription, hasError],
);
const Comp = asChild ? Slot : 'div';
return (
<FieldContext.Provider value={value}>
<Comp
ref={ref}
data-invalid={invalid || undefined}
data-disabled={disabled || undefined}
data-required={required || undefined}
className={fieldVariants({ orientation, disabled, className })}
{...rest}
/>
</FieldContext.Provider>
);
});
FieldRoot.displayName = 'Field.Root';
export const FieldLabel = React.forwardRef<HTMLLabelElement, FieldLabelProps>(function FieldLabel(
{ hideRequiredMarker = false, asChild = false, className, children, ...rest },
ref,
) {
const field = React.useContext(FieldContext);
const Comp = asChild ? Slot : 'label';
return (
<Comp
ref={ref}
htmlFor={rest.htmlFor ?? field?.id}
className={fieldLabelVariants({ size: field?.size ?? 'md', className })}
{...rest}
>
{children}
{field?.required && !hideRequiredMarker ? (
// Decorative: the control already carries `aria-required`, so reading
// the asterisk out would announce the requirement twice.
<span aria-hidden="true" className={fieldRequiredVariants()}>
*
</span>
) : null}
</Comp>
);
});
FieldLabel.displayName = 'Field.Label';
export const FieldDescription = React.forwardRef<HTMLParagraphElement, FieldTextProps>(
function FieldDescription({ asChild = false, className, ...rest }, ref) {
const field = React.useContext(FieldContext);
const setHasDescription = field?.setHasDescription;
React.useEffect(() => {
setHasDescription?.(true);
return () => setHasDescription?.(false);
}, [setHasDescription]);
const Comp = asChild ? Slot : 'p';
return (
<Comp
ref={ref}
id={field?.descriptionId}
className={fieldDescriptionVariants({ size: field?.size ?? 'md', className })}
{...rest}
/>
);
},
);
FieldDescription.displayName = 'Field.Description';
/**
* Renders nothing unless the field is invalid *and* there is a message.
*
* Keeping the empty case unmounted is deliberate: an always-present error
* paragraph is announced as an empty live region on some screen readers, and
* it reserves vertical space that makes forms jump as they validate.
*/
export const FieldError = React.forwardRef<HTMLParagraphElement, FieldTextProps>(
function FieldError({ asChild = false, className, children, ...rest }, ref) {
const field = React.useContext(FieldContext);
const setHasError = field?.setHasError;
const visible = Boolean(children) && (field?.invalid ?? true);
React.useEffect(() => {
setHasError?.(visible);
return () => setHasError?.(false);
}, [setHasError, visible]);
if (!visible) return null;
const Comp = asChild ? Slot : 'p';
return (
<Comp
ref={ref}
id={field?.errorId}
className={fieldErrorVariants({ size: field?.size ?? 'md', className })}
{...rest}
>
{children}
</Comp>
);
},
);
FieldError.displayName = 'Field.Error';
/** Read the surrounding field, if there is one. */
export function useFieldContext(): FieldContextValue | null {
return React.useContext(FieldContext);
}
/**
* Merges a control's own props with the field's, and returns what to spread.
*
* The control's explicit props always win. A `<Input disabled />` inside an
* enabled field is still disabled; the field only fills in what the caller left
* unsaid.
*/
export function useFieldControl(props: FieldControlProps = {}): FieldControlProps {
const field = React.useContext(FieldContext);
if (!field) return props;
const described = [
props['aria-describedby'],
field.hasDescription ? field.descriptionId : null,
field.hasError ? field.errorId : null,
]
.filter(Boolean)
.join(' ');
return {
...props,
id: props.id ?? field.id,
disabled: props.disabled ?? field.disabled,
required: props.required ?? field.required,
'aria-required': props['aria-required'] ?? (field.required || undefined),
'aria-invalid': props['aria-invalid'] ?? (field.invalid || undefined),
'aria-describedby': described || undefined,
};
}
export const Field = {
Root: FieldRoot,
Label: FieldLabel,
Description: FieldDescription,
Error: FieldError,
};
export { fieldVariants };API reference
Accepted values
- FieldSize
xssmmdlgxl
FieldProps
Also accepts everything from React.HTMLAttributes<HTMLDivElement>.
| Prop | Type | Description |
|---|---|---|
| id | string | Overrides the generated id used to tie the label to the control. |
| invalid | boolean | — |
| required | boolean | — |
| disabled | boolean | — |
| size | FieldSize | Sizes the label and helper text with the control. |
| orientation | 'vertical' | 'horizontal' | Puts the label beside the control — for checkboxes, switches and radios. |
| asChild | boolean | — |
FieldLabelProps
Also accepts everything from React.LabelHTMLAttributes<HTMLLabelElement>.
| Prop | Type | Description |
|---|---|---|
| hideRequiredMarker | boolean | Hides the asterisk without changing the control's `required` state. |
| asChild | boolean | — |
FieldTextProps
Also accepts everything from React.HTMLAttributes<HTMLParagraphElement>.
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | — |
FieldControlProps
| Prop | Type | Description |
|---|---|---|
| id | string | — |
| disabled | boolean | — |
| required | boolean | — |
| aria-invalid | React.AriaAttributes['aria-invalid'] | — |
| aria-describedby | string | — |
| aria-required | React.AriaAttributes['aria-required'] | — |