Skip to content
Noksha UIv0.1
Colour theme

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. Every tile copies on its own.

Pill stepper

A pill stepper — buttons attached to the field itself.

Card

Round buttons in a card instead of a bare spinner.

Soft chips

No box at all — just two soft, color-coded chips.

Solid bar
2

Solid, blocky buttons — built for a thumb, not a cursor.

Stacked chevrons

Stacked chevrons replace the left/right pair entirely.

Text buttons
1

No buttons at all — just underlined text doing the work.

Dashed outline

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, and one with a calling-code dropdown. Each tile copies on its own.

Formatted digits

Formatted as you type — only digits are ever stored.

Calling-code select

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.

Text message

Enter 10 digits to unlock the send button.

Email code

A valid address unlocks the send button.

Call countdown ring

The ring around the button is the countdown.

Push notification
iPhone 16 Pro · ending in 04

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.

Hostname parsing

Include the protocol — https://…

Domain suffix
.folio.dev

Live at yourname.folio.dev

Protocol chip
https://

The protocol is a fixed chip, not something you type.

Link preview

Paste a link to preview it.

Favicon check

Press Go to fetch the site's favicon — a real request, not a fake timer.

Paste to fill

Click the icon to paste instead of typing.

Short link

A 6-character code, deterministically derived from the URL.

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).

Practical

Accent bar
Command palette

Dark chrome and a shortcut chip — built to float over a page.

Clearable

The × only exists once there's something to clear.

Loading state

A real 500ms debounce, not an instant filter.

Recent searches

Empty and focused reveals history instead of nothing.

Grouped results

One list, two sources — components and guides side by side.

Voice search

Tap the mic and the field fills itself.

Expanding

Starts as a button — becomes a field on click.

Keyboard nav

Arrow keys move the highlight, Enter picks it.

Match count

The badge counts every match, not just the five shown elsewhere.

Highlighted match

The matched letters are bolded, not just the row.

Bold

Glass

Frosted glass over a live gradient, not a flat colour.

Gradient ring

A gradient border and a gradient button — same two colours, twice.

Neon

Focus the field and the border lights up.

Spotlight

Oversized and dark — built to sit on top of everything else.

Animated

Typewriter
▍

The placeholder types itself, one letter at a time.

Pulsing

A blurred glow breathes behind the whole field, always.

Sliding underline

No box at all — a gradient bar grows from the centre on focus.

Shimmer

Real Skeleton rows, not a spinner, while the 700ms debounce runs.

Staggered

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. Every tile copies with the shared hook included.

Plain

7 days from today — bookable up to 90 days out.

Gradient ring

3 days out — the ring matches the selected day.

Glass

Frosted glass over a gradient — the trigger, not just a card.

Neon

Focus the trigger and the border lights up — dark in both themes.

Boarding pass

The date itself is the icon — no separate calendar glyph.

Minimal

No box, no fill — just a rule and a heavy shadow when it opens.

Progress ring

7% of the way through the window — the ring is a progress bar, not decoration.

Countdown

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.

Validated native

2:00 AM — low-traffic hours run midnight to 5am.

Gradient ring

9:30 AM — same gradient ring as the date pickers.

Glass

Frosted glass over a gradient — the trigger itself, not a card behind it.

Neon

Focus the field and the border lights up — dark in both themes.

Digital clock
07
:
45

An LCD tile with its own steppers — no native input in sight.

AM/PM toggle
:

Silences notifications until 6:00 PM.

Analogue readout

The hands move live — a real analogue readout, not a static icon.

Clock dial
12369

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. Every tile copies on its own.

AA contrast ratio

4.47:1 against white — text this color won't pass AA.

Brand palette

Six brand presets, or the native picker for anything else.

Hex and swatch

Type a hex code or click the swatch — both write the same value.

Gradient card
#6366f1

The card itself fades into the picked color — a live preview, not a swatch.

Text sample
AaAa

4.47:1 — neither side is a safe bet on this color.

Circle verdict

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. Every tile copies on its own.

Inputs and buttons

Plain input

PNG or SVG, up to 2MB.

Button trigger
No file chosen

A real Button, not the browser's own — same file input underneath.

Removable row

Picked becomes a removable row — no native text to fight with.

Upload progress

A gradient progress bar that actually moves.

Dropzones

Dropzone

Real drag-and-drop, not just a styled label.

Gradient border

The border turns into a gradient the moment a file crosses it.

Glass

Frosted glass over a gradient — the dropzone itself, not a card behind it.

Neon

Dark in both themes — the border glows the moment a file crosses it.

Drop and preview

Drop and preview in one motion — the zone becomes the thumbnail.

Images and avatars

Thumbnail beside
JPG or PNG

A real thumbnail, built from the file itself — not a filename.

Avatar with camera badge

A camera badge that's always visible, not one that only shows up on hover.

Multi-file gallery

0 files — each tile removable on its own.

Avatar upload ring

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.

Grouped digits

Digits are grouped with thousands separators as you type.

Price tag
$4,900

The number is the whole design — everything else is secondary.

Currency select

Pick a currency from the dropdown — the symbol updates with it.

Budget card
$

64% of a $5,000 monthly limit.

The card itself is the progress bar — not a separate widget.

Neon ticker
$

A ticker readout — dark in both themes, glows the moment you focus it.

Slider-linked

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>.

PropTypeDescription
idstringOverrides the generated id used to tie the label to the control.
invalidboolean—
requiredboolean—
disabledboolean—
sizeFieldSizeSizes the label and helper text with the control.
orientation'vertical' | 'horizontal'Puts the label beside the control — for checkboxes, switches and radios.
asChildboolean—

FieldLabelProps

Also accepts everything from React.LabelHTMLAttributes<HTMLLabelElement>.

PropTypeDescription
hideRequiredMarkerbooleanHides the asterisk without changing the control's `required` state.
asChildboolean—

FieldTextProps

Also accepts everything from React.HTMLAttributes<HTMLParagraphElement>.

PropTypeDescription
asChildboolean—

FieldControlProps

PropTypeDescription
idstring—
disabledboolean—
requiredboolean—
aria-invalidReact.AriaAttributes['aria-invalid']—
aria-describedbystring—
aria-requiredReact.AriaAttributes['aria-required']—