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.

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.

2

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

Stacked chevrons replace the left/right pair entirely.

1

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.

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.

Include the protocol — https://…

.folio.dev

Live at yourname.folio.dev

https://

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.

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.

07
:
45

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.

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.

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.

#6366f1

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

AaAa

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.

No file chosen

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.

JPG or PNG

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.

$4,900

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

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']