Skip to content

Form Fields

Inputs, textareas, selects, labels, helper text, switches and checkboxes styled for the newt/ui dark theme.

Inputs, textareas, selects, labels, helper text, switches and checkboxes styled for the newt/ui dark theme. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

Root class newt-field
import {
  Field,
  FieldHelp,
  Input,
  Label,
  Switch,
} from "@/registry/bases/newt/ui/form-fields"

export default function FormFieldsDemo() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-6">
      <Field>
        <Label htmlFor="newt-input-example">Label</Label>
        <Input id="newt-input-example" placeholder="Placeholder" />
        <FieldHelp>Helper text</FieldHelp>
      </Field>

      <div className="flex items-center justify-between text-newt-text-primary">
        <span>Toggle label</span>
        <Switch aria-label="Toggle label" />
      </div>
    </div>
  )
}
<script setup lang="ts">
import { ref } from "vue"
import {
  Field,
  FieldHelp,
  Input,
  Label,
  Switch,
} from "~~/registry/bases/newt/ui/form-fields"

const value = ref("")
const enabled = ref(false)
</script>

<template>
  <div class="flex w-full max-w-sm flex-col gap-6">
    <Field>
      <Label for="newt-input-example">Label</Label>
      <Input
        id="newt-input-example"
        v-model="value"
        placeholder="Placeholder"
      />
      <FieldHelp>Helper text</FieldHelp>
    </Field>

    <div class="flex items-center justify-between text-newt-text-primary">
      <span>Toggle label</span>
      <Switch v-model="enabled" aria-label="Toggle label" />
    </div>
  </div>
</template>

Installation

pnpm dlx newtui@latest add form-fields
npx newtui@latest add form-fields
yarn dlx newtui@latest add form-fields
bunx newtui@latest add form-fields

Copy the source into your project

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface FieldProps extends React.ComponentProps<"div"> {
  /**
   * Marks the whole field as failing validation. The controls inside it read
   * `data-invalid` from this wrapper, so one prop restyles the group rather
   * than each control being told separately.
   */
  invalid?: boolean
}

const Field = React.forwardRef<HTMLDivElement, FieldProps>(
  ({ className, invalid = false, ...props }, ref) => (
    <div
      ref={ref}
      data-invalid={invalid || undefined}
      className={cn("group/field flex flex-col gap-2", className)}
      {...props}
    />
  )
)
Field.displayName = "Field"

const Label = React.forwardRef<HTMLLabelElement, React.ComponentProps<"label">>(
  ({ className, ...props }, ref) => (
    <label
      ref={ref}
      className={cn(
        "text-xs font-bold uppercase tracking-[0.02em] text-newt-text-secondary",
        className
      )}
      {...props}
    />
  )
)
Label.displayName = "Label"

/*
 * The invalid border is driven by the field wrapper rather than by a prop on
 * each control, so a form library that only knows "this field is in error"
 * needs to say it once.
 */
const controlClassName =
  "w-full rounded-sm border border-newt-border bg-newt-bg-input px-3 py-2.5 font-sans text-sm text-newt-text-primary transition-colors duration-fast ease-newt placeholder:text-newt-text-muted focus:border-newt-brand focus:outline-none group-data-[invalid]/field:border-newt-danger aria-[invalid=true]:border-newt-danger"

const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
  ({ className, type = "text", ...props }, ref) => (
    <input
      ref={ref}
      type={type}
      className={cn(controlClassName, className)}
      {...props}
    />
  )
)
Input.displayName = "Input"

const Textarea = React.forwardRef<
  HTMLTextAreaElement,
  React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => (
  <textarea
    ref={ref}
    className={cn(controlClassName, "min-h-[80px] resize-y", className)}
    {...props}
  />
))
Textarea.displayName = "Textarea"

const Select = React.forwardRef<
  HTMLSelectElement,
  React.ComponentProps<"select">
>(({ className, ...props }, ref) => (
  <select ref={ref} className={cn(controlClassName, className)} {...props} />
))
Select.displayName = "Select"

export interface FieldHelpProps extends React.ComponentProps<"span"> {
  /**
   * `error` colours the text and announces it: a validation message that
   * appears after a submit is useless to a reader who never sees it.
   */
  variant?: "help" | "error"
}

const FieldHelp = React.forwardRef<HTMLSpanElement, FieldHelpProps>(
  ({ className, variant = "help", ...props }, ref) => (
    <span
      ref={ref}
      role={variant === "error" ? "alert" : undefined}
      className={cn(
        "text-xs",
        variant === "error" ? "text-newt-danger" : "text-newt-text-muted",
        className
      )}
      {...props}
    />
  )
)
FieldHelp.displayName = "FieldHelp"

export interface SwitchProps extends Omit<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  "onChange"
> {
  checked?: boolean
  defaultChecked?: boolean
  onCheckedChange?: (checked: boolean) => void
}

const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
  (
    {
      className,
      checked,
      defaultChecked = false,
      onCheckedChange,
      onClick,
      disabled,
      ...props
    },
    ref
  ) => {
    const [internal, setInternal] = React.useState(defaultChecked)
    const isControlled = checked !== undefined
    const on = isControlled ? checked : internal

    const toggle = (event: React.MouseEvent<HTMLButtonElement>) => {
      onClick?.(event)
      if (event.defaultPrevented || disabled) return
      const next = !on
      if (!isControlled) setInternal(next)
      onCheckedChange?.(next)
    }

    return (
      <button
        ref={ref}
        type="button"
        role="switch"
        aria-checked={on}
        data-state={on ? "checked" : "unchecked"}
        disabled={disabled}
        onClick={toggle}
        className={cn(
          "relative inline-flex h-6 w-10 shrink-0 cursor-pointer rounded-full border border-newt-border bg-newt-bg-elevated transition-colors duration-base ease-newt disabled:cursor-not-allowed disabled:opacity-50",
          on && "border-newt-online bg-newt-online",
          className
        )}
        {...props}
      >
        <span
          aria-hidden="true"
          className={cn(
            "absolute start-0.5 top-0.5 h-[18px] w-[18px] rounded-full bg-newt-text-muted transition-[transform,background-color] duration-base ease-newt",
            on && "translate-x-[calc(var(--newt-dir)*1rem)] bg-white"
          )}
        />
      </button>
    )
  }
)
Switch.displayName = "Switch"

export interface CheckboxProps extends Omit<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  "onChange"
> {
  checked?: boolean
  defaultChecked?: boolean
  onCheckedChange?: (checked: boolean) => void
}

const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
  (
    {
      className,
      checked,
      defaultChecked = false,
      onCheckedChange,
      onClick,
      disabled,
      ...props
    },
    ref
  ) => {
    const [internal, setInternal] = React.useState(defaultChecked)
    const isControlled = checked !== undefined
    const on = isControlled ? checked : internal

    const toggle = (event: React.MouseEvent<HTMLButtonElement>) => {
      onClick?.(event)
      if (event.defaultPrevented || disabled) return
      const next = !on
      if (!isControlled) setInternal(next)
      onCheckedChange?.(next)
    }

    return (
      <button
        ref={ref}
        type="button"
        role="checkbox"
        aria-checked={on}
        data-state={on ? "checked" : "unchecked"}
        disabled={disabled}
        onClick={toggle}
        className={cn(
          "inline-flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border border-newt-border bg-newt-bg-input text-white transition-colors duration-fast ease-newt focus-visible:border-newt-brand focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
          on && "border-newt-brand bg-newt-brand",
          className
        )}
        {...props}
      >
        {on ? (
          <svg
            aria-hidden="true"
            viewBox="0 0 16 16"
            width="12"
            height="12"
            fill="none"
            stroke="currentColor"
            strokeWidth="2.5"
            strokeLinecap="round"
            strokeLinejoin="round"
          >
            <path d="M3 8.5 6.5 12 13 4.5" />
          </svg>
        ) : null}
      </button>
    )
  }
)
Checkbox.displayName = "Checkbox"

export { Field, Label, Input, Textarea, Select, FieldHelp, Switch, Checkbox }
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

/*
 * `invalid` marks the whole field. The controls inside read `data-invalid`
 * from this wrapper, so a form library that only knows "this field is in
 * error" says it once rather than per control.
 */
const props = defineProps<{
  class?: HTMLAttributes["class"]
  invalid?: boolean
}>()
</script>

<template>
  <div
    :data-invalid="props.invalid || undefined"
    :class="cn('group/field flex flex-col gap-2', props.class)"
  >
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"]; for?: string }>()
</script>

<template>
  <label
    :for="props.for"
    :class="
      cn(
        'text-xs font-bold uppercase tracking-[0.02em] text-newt-text-secondary',
        props.class
      )
    "
  >
    <slot />
  </label>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

import { controlClassName } from "."

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const model = defineModel<string | number>()
</script>

<template>
  <input v-model="model" :class="cn(controlClassName, props.class)" />
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

import { controlClassName } from "."

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const model = defineModel<string>()
</script>

<template>
  <textarea
    v-model="model"
    :class="cn(controlClassName, 'min-h-[80px] resize-y', props.class)"
  />
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

import { controlClassName } from "."

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const model = defineModel<string | number>()
</script>

<template>
  <select v-model="model" :class="cn(controlClassName, props.class)">
    <slot />
  </select>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

/*
 * `error` colours the text and announces it: a validation message that appears
 * after a submit is useless to a reader who never sees it.
 */
const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    variant?: "help" | "error"
  }>(),
  { variant: "help" }
)
</script>

<template>
  <span
    :role="props.variant === 'error' ? 'alert' : undefined"
    :class="
      cn(
        'text-xs',
        props.variant === 'error' ? 'text-newt-danger' : 'text-newt-text-muted',
        props.class
      )
    "
  >
    <slot />
  </span>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{
  class?: HTMLAttributes["class"]
  disabled?: boolean
}>()

const checked = defineModel<boolean>({ default: false })

function toggle() {
  if (props.disabled) return
  checked.value = !checked.value
}
</script>

<template>
  <button
    type="button"
    role="switch"
    :aria-checked="checked"
    :data-state="checked ? 'checked' : 'unchecked'"
    :disabled="props.disabled"
    :class="
      cn(
        'relative inline-flex h-6 w-10 shrink-0 cursor-pointer rounded-full border border-newt-border bg-newt-bg-elevated transition-colors duration-base ease-newt disabled:cursor-not-allowed disabled:opacity-50',
        checked && 'border-newt-online bg-newt-online',
        props.class
      )
    "
    @click="toggle"
  >
    <span
      aria-hidden="true"
      :class="
        cn(
          'absolute start-0.5 top-0.5 h-[18px] w-[18px] rounded-full bg-newt-text-muted transition-[transform,background-color] duration-base ease-newt',
          checked && 'translate-x-[calc(var(--newt-dir)*1rem)] bg-white'
        )
      "
    />
  </button>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{
  class?: HTMLAttributes["class"]
  disabled?: boolean
}>()

const checked = defineModel<boolean>({ default: false })

function toggle() {
  if (props.disabled) return
  checked.value = !checked.value
}
</script>

<template>
  <button
    type="button"
    role="checkbox"
    :aria-checked="checked"
    :data-state="checked ? 'checked' : 'unchecked'"
    :disabled="props.disabled"
    :class="
      cn(
        'inline-flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border border-newt-border bg-newt-bg-input text-white transition-colors duration-fast ease-newt focus-visible:border-newt-brand focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
        checked && 'border-newt-brand bg-newt-brand',
        props.class
      )
    "
    @click="toggle"
  >
    <svg
      v-if="checked"
      aria-hidden="true"
      viewBox="0 0 16 16"
      width="12"
      height="12"
      fill="none"
      stroke="currentColor"
      stroke-width="2.5"
      stroke-linecap="round"
      stroke-linejoin="round"
    >
      <path d="M3 8.5 6.5 12 13 4.5" />
    </svg>
  </button>
</template>
export { default as Field } from "./Field.vue"
export { default as Label } from "./Label.vue"
export { default as Input } from "./Input.vue"
export { default as Textarea } from "./Textarea.vue"
export { default as Select } from "./Select.vue"
export { default as FieldHelp } from "./FieldHelp.vue"
export { default as Switch } from "./Switch.vue"
export { default as Checkbox } from "./Checkbox.vue"

/*
 * The invalid border is driven by the field wrapper rather than by a prop on
 * each control, so a form library that only knows "this field is in error"
 * needs to say it once.
 */
export const controlClassName =
  "w-full rounded-sm border border-newt-border bg-newt-bg-input px-3 py-2.5 font-sans text-sm text-newt-text-primary transition-colors duration-fast ease-newt placeholder:text-newt-text-muted focus:border-newt-brand focus:outline-none group-data-[invalid]/field:border-newt-danger aria-[invalid=true]:border-newt-danger"

Update the import paths

The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.

Usage

import { FormFields } from "@/components/ui/form-fields"

export function Example() {
  return <FormFields />
}
<script setup lang="ts">
import { Field, Label, Input, Textarea, Select, FieldHelp, Switch, Checkbox } from "@/components/ui/form-fields"
</script>

<template>
  <Field />
</template>

API reference

Field

PropType
invalid?boolean | undefined

Marks the whole field as failing validation. The controls inside it read `data-invalid` from this wrapper, so one prop restyles the group rather than each control being told separately.

Typeboolean | undefined

FieldHelp

PropType
variant?"help" | "error" | undefined

`error` colours the text and announces it: a validation message that appears after a submit is useless to a reader who never sees it.

Type"help" | "error" | undefined

Switch

PropType
checked?boolean | undefined

Optional checked value.

Typeboolean | undefined
defaultChecked?boolean | undefined

Optional defaultChecked value.

Typeboolean | undefined
onCheckedChange?(checked: boolean) => void | undefined

Optional onCheckedChange value.

Type(checked: boolean) => void | undefined

Checkbox

PropType
checked?boolean | undefined

Optional checked value.

Typeboolean | undefined
defaultChecked?boolean | undefined

Optional defaultChecked value.

Typeboolean | undefined
onCheckedChange?(checked: boolean) => void | undefined

Optional onCheckedChange value.

Type(checked: boolean) => void | undefined

The registry exports Field, Label, Input, Textarea, Select, FieldHelp, Switch, Checkbox. Parts without a table accept the native attributes for the element they render; React uses className, while Vue uses class and fallthrough attributes.

Accessibility

Associate every control with a visible label. Connect help or error text with aria-describedby, and pair invalid styling with aria-invalid rather than colour alone.

Tokens

Form Fields reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer. In the plain HTML and CSS distribution the same component is newt-field.