Custom fields
Turn any React UI into a field with useField and FieldShell.
Any React UI can be a field. useField connects it to the form — value,
validation, errors, focus, ARIA and storage — and FieldShell renders the
standard label, help text and error around it in the active theme's style.
Custom fields are collected like built-in ones, so the server validates and
stores them too.
import { FieldShell, useField } from '@open-form-build/core';
import './plan-picker.css';
const plans = [
{ value: 'free', label: 'Free' },
{ value: 'pro', label: 'Pro' },
];
export function PlanPicker({ name, label }: { name: string; label: string }) {
const field = useField({
name,
label,
type: 'radio', // built-in option check; omit for free-form values
required: true,
options: plans,
});
const { 'aria-invalid': invalid, 'aria-describedby': describedBy } = field.controlProps;
return (
<FieldShell field={field} as="fieldset">
<div className="plan-grid">
{plans.map((plan, i) => (
<label key={plan.value} className="plan-tile">
<input
ref={i === 0 ? field.ref : undefined}
type="radio"
name={field.name}
value={plan.value}
checked={field.value === plan.value}
aria-invalid={invalid}
aria-describedby={describedBy}
onChange={() => field.setValue(plan.value)}
onBlur={field.onBlur}
/>
{plan.label}
</label>
))}
</div>
</FieldShell>
);
}<Page title="Plan">
<PlanPicker name="plan" label="Choose a plan" />
</Page>useField options
Prop
Type
type is one of 'text', 'email', 'url', 'tel', 'number',
'textarea', 'select', 'radio', 'checkbox', 'checkbox-group',
'date' or 'custom'. Pick the built-in type whose value your control
produces to get its coercion and checks for free — a slider that yields a
number should say 'number' and pass min / max. The constraint props of
built-in fields are accepted too: minLength, maxLength, pattern,
patternMessage, min, max, integer, step, minSelected, maxSelected.
With 'custom', only required and validate apply, and the value is stored
as it is. Keep it JSON-serializable and nested at most 10 levels deep; deeper
values are refused, so Responses search and exports can always read them.
What it returns
useField returns a FieldApi; both it and UseFieldOptions are exported for
typing your own components.
| Key | What |
|---|---|
name | The field's name. Give it to every input of a native radio or checkbox group. |
value | The current answer (raw: a text input holds a string while typing). |
setValue(value) | Update the answer. Re-validates if the field is showing an error. |
onBlur() | Call when the control loses focus; shows format errors. |
error / invalid | The current error message, and whether there is one. |
required | Whether the field is required. |
label / description | Translated into the language being shown. |
id, errorId, descriptionId | DOM ids the shell and ARIA attributes use. |
ref | Attach to the element that should receive focus when the field is invalid. |
controlProps | id, name, aria-invalid, aria-describedby, aria-required and onBlur, for the main control. |
Native controls
For a single native input, spread controlProps and attach ref. The label's
htmlFor already points at controlProps.id.
import { FieldShell, useField } from '@open-form-build/core';
export function Budget({ name, label }: { name: string; label: string }) {
const field = useField({ name, label, type: 'number', min: 0, max: 10000, defaultValue: 1000 });
return (
<FieldShell field={field}>
<input
{...field.controlProps}
ref={field.ref}
type="range"
min={0}
max={10000}
step={500}
value={Number(field.value ?? 0)}
onChange={(event) => field.setValue(Number(event.target.value))}
/>
<output>{String(field.value ?? 0)}</output>
</FieldShell>
);
}FieldShell
FieldShell takes the field from useField and your control as children.
Pass as="fieldset" for a group of inputs — the label becomes a <legend> —
hideLabel when the control shows the label itself (add <RequiredMark />
after it when field.required), and hideDescription when it renders
field.description on its own. It renders the theme's Field component, so
custom fields match the theme automatically.
Styling
Build on the theme tokens so custom fields follow every theme and dark mode:
.plan-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 160px), 1fr));
gap: 0.5rem;
}
.plan-tile {
position: relative;
display: block;
padding: 0.875rem 1rem;
border: 1px solid var(--of-control);
border-radius: var(--of-radius);
background: var(--of-input);
color: var(--of-fg);
cursor: pointer;
}
.plan-tile input {
position: absolute;
opacity: 0;
}
.plan-tile:has(:checked) {
border-color: var(--of-primary);
}
.plan-tile:has(:focus-visible) {
outline: 2px solid var(--of-primary);
outline-offset: 2px;
}Rules
- Spread
controlPropson the main control. In a group, putrefon the first control and passaria-invalidandaria-describedbyto each one. - Attach
refso a failed Next can move focus to the field. - Call
onBlurwhen focus leaves, or format errors won't appear until Next. - Keep native inputs where one exists. Restyle a radio or checkbox — a
visually hidden input with a styled label, or
appearance: none— instead of rebuilding it fromdivs, and keyboards and screen readers keep working. - Show focus clearly: a solid 2px
var(--of-primary)outline, and control outlines invar(--of-control)rather than the decorative--of-border. - Don't hold the value in
useState. The form owns it; readfield.valueand callfield.setValue.