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.
newt-fieldimport {
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-fieldsnpx newtui@latest add form-fieldsyarn dlx newtui@latest add form-fieldsbunx newtui@latest add form-fieldsCopy 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
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.
boolean | undefinedFieldHelp
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.
"help" | "error" | undefinedSwitch
checked?boolean | undefined
Optional checked value.
boolean | undefineddefaultChecked?boolean | undefined
Optional defaultChecked value.
boolean | undefinedonCheckedChange?(checked: boolean) => void | undefined
Optional onCheckedChange value.
(checked: boolean) => void | undefinedCheckbox
checked?boolean | undefined
Optional checked value.
boolean | undefineddefaultChecked?boolean | undefined
Optional defaultChecked value.
boolean | undefinedonCheckedChange?(checked: boolean) => void | undefined
Optional onCheckedChange value.
(checked: boolean) => void | undefinedThe 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.