Skip to content
Star on GitHub
Forms and inputsForm
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Form

A project brief with inline checks and an agent that drafts the rest. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add form
New project brief
Type
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add form

React

$npx designme add form --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Form is a short form, set up as a project brief by default, with a Draft with agent button that fills the fields in for the person. Fields the agent filled carry a Drafted mark beside their label until the person edits them, so it is clear what still needs a human read. On submit every field is checked, and a confirmation replaces the form once it goes through.

  • When an agent can fill in a brief, ticket or request from context and a person should review it
  • When people need to see which answers they typed and which the agent wrote
  • When a short form needs inline messages per field instead of a page of errors
  • When creating something should hand off to the agent, as in "the agent is gathering references now"
03

Usage

Import it and drive it from your own state.

Import

import { Form } from '@/components/designme/form/Form';

Example

The demo above as a React component, 44 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { useScript, useTakeover, useWait } from '../lib/useScript';
import { Form, type FormField, type FormHandle, type FormValues } from './Form';

const FIELDS: FormField[] = [
  { key: 'name', label: 'Project name', placeholder: 'Pricing refresh', maxLength: 40, validate: v => !v.trim() && 'Give it a name' },
  { key: 'goal', label: 'Goal', type: 'textarea', rows: 2, placeholder: 'What should change, and why?', validate: v => v.trim().length < 12 && 'Add a sentence about the goal' },
  { key: 'type', label: 'Type', type: 'choice', options: [{ value: 'web', label: 'Website' }, { value: 'app', label: 'App' }, { value: 'brand', label: 'Brand' }], validate: v => !v && 'Pick a type' },
  { key: 'due', label: 'Due', placeholder: 'Nov 14' }
];

/* ---------- Form: a project brief; the agent can draft it, and submit checks every field ---------- */
export default function FormDemo() {
  const [values, setValues] = useState<FormValues>({});
  const latest = useRef<FormValues>({});
  const set = (v: FormValues) => { latest.current = v; setValues(v); };
  const wait = useWait();

  // the agent types each field in, a letter at a time
  const typeIn = async (key: string, text: string) => {
    set({ ...latest.current, [key]: '' });
    for (let i = 1; i <= text.length; i++) { set({ ...latest.current, [key]: text.slice(0, i) }); await wait(18); }
  };
  const draft = async () => {
    if (!latest.current.name) await typeIn('name', 'Pricing refresh');
    await typeIn('goal', 'Lift trial to paid by showing annual plans first and keeping one CTA per card.');
    if (!latest.current.type) set({ ...latest.current, type: 'web' });
    if (!latest.current.due) await typeIn('due', 'Nov 14');
  };

  // a moment after it appears, the agent drafts the brief, unless someone has started on it
  // the preview has the agent fill it in once on its own, unless you take over first
  const form = useRef<FormHandle>(null), ref = useRef<HTMLDivElement>(null), mine = useTakeover(ref);
  useScript(async s => { await s.wait(1400); if (!mine() && !latest.current.name) await form.current?.draft(); });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <Form ref={form} fields={FIELDS} values={values} onValuesChange={set} onDraft={draft} onSubmit={() => wait(800)} />
    </div>
  );
}
04

Props

Everything Form takes.

PropTypeWhat it does
titleReactNodeDefaults to "New project brief"
fieldsrequiredFormField[]
valuesFormValuesThe values by field key. Leave it out to let the fields hold their own
defaultValuesFormValues
onValuesChange(values: FormValues) => void
onDraft() => void | Promise<void>Adds "Draft with agent". Fill the values in while the promise runs; the fields it fills are marked Drafted beside their label until someone edits them
draftLabelstring
draftingLabelstring
draftedLabelstring
draftedMessagestringShown beside the submit button after a draft. Defaults to "Review what the agent drafted"
onSubmit(values: FormValues) => void | Promise<void>Called with the values once every field passes. Return a promise and the button spins until it settles
submitLabelstring
invalidMessagestringShown beside the submit button when a field needs a look
errorMessagestringShown when onSubmit rejects without a message of its own
doneTitle(values: FormValues) => ReactNodeThe line after a submit. Defaults to "<first field> created"
doneDetailReactNode
classNamestring
refRef<FormHandle>Gives access to draft(), to start the agent's draft without a click
05

Code

The full source, as the CLI installs it.

Form.tsxReact · 175 lines
'use client';
import { useEffect, useImperativeHandle, useRef, useState, type FormEvent, type ReactNode, type Ref } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Spin, Swap, Think } from '../lib/motion';
import '../designme.css';
import './form.css';

export type FormValues = Record<string, string>;

/** What a ref to the form can do */
export interface FormHandle {
  /** Runs onDraft as if "Draft with agent" were pressed, with the same Drafting and Drafted states */
  draft: () => Promise<void>;
}

export interface FormField {
  key: string;
  /** Like "Project name" */
  label: string;
  /** text (the default), textarea, or choice: a row of chips that picks one */
  type?: 'text' | 'textarea' | 'choice';
  placeholder?: string;
  /** Shows a counter like 0/40 and stops typing there */
  maxLength?: number;
  /** Rows for a textarea. Defaults to 2 */
  rows?: number;
  /** The chips of a choice field */
  options?: { value: string; label: string }[];
  autoComplete?: string;
  /** Returns the message to show when the value won't do, like "Give it a name". Checked on submit */
  validate?: (value: string, values: FormValues) => string | false | null | undefined;
}

export interface FormProps {
  /** Defaults to "New project brief" */
  title?: ReactNode;
  fields: FormField[];
  /** The values by field key. Leave it out to let the fields hold their own */
  values?: FormValues;
  defaultValues?: FormValues;
  onValuesChange?: (values: FormValues) => void;
  /** Adds "Draft with agent". Fill the values in while the promise runs; the fields it fills are marked Drafted beside their label until someone edits them */
  onDraft?: () => void | Promise<void>;
  draftLabel?: string;
  draftingLabel?: string;
  draftedLabel?: string;
  /** Shown beside the submit button after a draft. Defaults to "Review what the agent drafted" */
  draftedMessage?: string;
  /** Called with the values once every field passes. Return a promise and the button spins until it settles */
  onSubmit?: (values: FormValues) => void | Promise<void>;
  submitLabel?: string;
  /** Shown beside the submit button when a field needs a look */
  invalidMessage?: string;
  /** Shown when onSubmit rejects without a message of its own */
  errorMessage?: string;
  /** The line after a submit. Defaults to "<first field> created" */
  doneTitle?: (values: FormValues) => ReactNode;
  doneDetail?: ReactNode;
  className?: string;
  /** Gives access to draft(), to start the agent's draft without a click */
  ref?: Ref<FormHandle>;
}

/** A short form an agent can draft for you: fields that check themselves on submit, and a confirmation. */
export function Form({
  title = 'New project brief', fields, values, defaultValues = {}, onValuesChange, onDraft, draftLabel = 'Draft with agent', draftingLabel = 'Drafting',
  draftedLabel = 'Drafted', draftedMessage = 'Review what the agent drafted', onSubmit, submitLabel = 'Create brief', invalidMessage = 'A few fields need a look',
  errorMessage = 'Something went wrong, try again', doneTitle, doneDetail = 'The agent is gathering references now', className, ref
}: FormProps) {
  const [vals, setVals] = useControllable(values, defaultValues, onValuesChange);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [ai, setAi] = useState<string[]>([]);
  const [draft, setDraft] = useState<'idle' | 'drafting' | 'drafted'>('idle');
  const [message, setMessage] = useState('');
  const [busy, setBusy] = useState(false);
  const [done, setDone] = useState(false);
  const own = useRef<string | null>(null);

  // a changed value clears its field's message, whoever changed it; while a draft runs, the text fields it fills are marked Drafted
  // until the person edits them
  const before = useRef(vals);
  useEffect(() => {
    const was = before.current; before.current = vals;
    const edited = own.current; own.current = null;
    const changed = fields.filter(f => f.key !== edited && (was[f.key] ?? '') !== (vals[f.key] ?? '')).map(f => f.key);
    if (!changed.length) return;
    setErrors(e => Object.fromEntries(Object.entries(e).filter(([k]) => !changed.includes(k))));
    const filled = draft === 'drafting' ? changed.filter(k => fields.find(f => f.key === k)?.type !== 'choice') : [];
    if (filled.length) setAi(a => [...new Set([...a, ...filled])]);
  }, [vals]); // eslint-disable-line react-hooks/exhaustive-deps

  const set = (key: string, value: string) => {
    own.current = key;
    setVals({ ...vals, [key]: value });
    setErrors(({ [key]: _, ...rest }) => rest);
    setAi(a => a.filter(k => k !== key));
  };

  const runDraft = async () => {
    if (!onDraft || draft === 'drafting') return;
    setDraft('drafting');
    try { await onDraft(); } finally { setDraft('drafted'); }
    setMessage(draftedMessage);
  };
  useImperativeHandle(ref, () => ({ draft: runDraft }));

  const submit = async (e: FormEvent) => {
    e.preventDefault();
    if (busy) return;
    const found = { ...errors };
    for (const f of fields) { const msg = f.validate?.(vals[f.key] ?? '', vals); if (msg && !found[f.key]) found[f.key] = msg; }
    setErrors(found);
    if (fields.some(f => f.validate?.(vals[f.key] ?? '', vals))) { setMessage(invalidMessage); return; }
    setBusy(true);
    try { await onSubmit?.(vals); } catch (err) { setBusy(false); setMessage(err instanceof Error && err.message ? err.message : errorMessage); return; }
    setBusy(false); setDone(true);
  };

  if (done) {
    const first = fields[0] ? vals[fields[0].key] : '';
    return (
      <form data-dm="" className={cx('dm-fmx dm-panel', className)} noValidate onSubmit={e => e.preventDefault()}>
        <div className="dm-fmx-done" role="status">
          <Icon name="ok" className="dm-enter" />
          <b {...enter(70)}>{doneTitle ? doneTitle(vals) : `${first} created`}</b>
          <span className="dm-muted dm-enter" style={enter(140).style}>{doneDetail}</span>
        </div>
      </form>
    );
  }

  return (
    <form data-dm="" className={cx('dm-fmx dm-panel', className)} noValidate onSubmit={submit}>
      <div className="dm-fmx-h">
        <b>{title}</b>
        {onDraft && (
          <button type="button" className="dm-pill dm-soft dm-sm dm-fmx-ai" onClick={runDraft}>
            {draft === 'drafting' ? <><Think />{draftingLabel}</> : draft === 'drafted' ? <><Icon name="check" size="xs" />{draftedLabel}</> : <><Icon name="spark" size="xs" />{draftLabel}</>}
          </button>
        )}
      </div>
      {fields.map(f => {
        const value = vals[f.key] ?? '', err = errors[f.key];
        const state = cx('dm-fmx-f', err && 'dm-bad', ai.includes(f.key) && 'dm-ai');
        const note = err && <span className="dm-er" role="alert">{err}</span>;
        if (f.type === 'choice') return (
          <div key={f.key} className={state}>
            <span>{f.label}</span>
            <div className="dm-fmx-ty" role="group" aria-label={f.label}>
              {f.options?.map(o => <button type="button" key={o.value} className={cx('dm-chip dm-sm', value === o.value && 'dm-on')} aria-pressed={value === o.value} onClick={() => set(f.key, o.value)}>{o.label}</button>)}
            </div>
            {note}
          </div>
        );
        return (
          <label key={f.key} className={state}>
            <span>{f.label}<i className="dm-fmx-dr" aria-hidden="true"><Icon name="spark" size="xs" />{draftedLabel}</i></span>
            {f.type === 'textarea'
              ? <textarea name={f.key} rows={f.rows ?? 2} placeholder={f.placeholder} maxLength={f.maxLength} autoComplete={f.autoComplete} aria-invalid={!!err || undefined} value={value} onChange={e => set(f.key, e.target.value)} />
              : <input name={f.key} placeholder={f.placeholder} maxLength={f.maxLength} autoComplete={f.autoComplete} aria-invalid={!!err || undefined} value={value} onChange={e => set(f.key, e.target.value)} />}
            {f.maxLength && f.type !== 'textarea' && <em className="dm-fmx-c dm-num">{value.length}/{f.maxLength}</em>}
            {note}
          </label>
        );
      })}
      <div className="dm-fmx-ft">
        <Swap className="dm-fmx-ms dm-muted">{message}</Swap>
        <button type="submit" className="dm-pill dm-primary dm-sm dm-fmx-go">{busy ? <Spin /> : submitLabel}</button>
      </div>
    </form>
  );
}
form.cssReact · 28 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Form ---------- */
.dm-fmx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3);max-width:380px;margin:0 auto;width:100%}
.dm-fmx-h{display:flex;align-items:center;justify-content:space-between;gap:var(--dm-s-2)}
.dm-fmx-h b{font-weight:500;font-size:14px}
.dm-fmx-f{display:flex;flex-direction:column;gap:5px;font-size:12.5px;font-weight:500;position:relative}
.dm-fmx-f input,.dm-fmx-f textarea{border:0;outline:0;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);padding:10px var(--dm-s-3);font:inherit;font-size:13.5px;line-height:20px;font-weight:400;color:var(--dm-fg);resize:none;transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-fmx-f :is(input,textarea){corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-fmx-f input::placeholder,.dm-fmx-f textarea::placeholder{color:var(--dm-muted)}
.dm-fmx-f input:focus,.dm-fmx-f textarea:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
/* a field marked wrong keeps its red edge, focused or not, until it is fixed; the message says what is wrong */
.dm-fmx-f.dm-bad input,.dm-fmx-f.dm-bad textarea{box-shadow:inset 0 0 0 1px var(--dm-bad)}
.dm-fmx-f .dm-er{font-size:11.5px;color:var(--dm-bad);font-weight:400;animation:dm-enter .35s var(--dm-ease) backwards}
/* what the agent drafted says so quietly beside its label, until someone edits it */
.dm-fmx-f>span:first-child{display:flex;align-items:center;gap:var(--dm-s-2)}
.dm-fmx-dr{display:inline-flex;align-items:center;gap:3px;font-style:normal;font-size:11px;font-weight:400;color:var(--dm-muted);opacity:0;transform:translateX(-3px);transition:opacity var(--dm-t) var(--dm-ease),transform var(--dm-t) var(--dm-ease)}
.dm-fmx-dr .dm-i{color:var(--dm-accent-fg,var(--dm-accent))}
.dm-fmx-f.dm-ai .dm-fmx-dr{opacity:1;transform:none}
.dm-fmx-c{position:absolute;right:12px;top:24px;height:40px;display:flex;align-items:center;font-style:normal;font-size:11px;color:var(--dm-muted);font-weight:400;pointer-events:none}
.dm-fmx-ty{display:flex;gap:6px}
/* the types are white chips with a dimmed grey edge; the picked one's edge is a step stronger */
.dm-fmx-ty .dm-chip{background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-pick-line) 50%,transparent)}
@media (hover:hover) and (pointer:fine){.dm-fmx-ty .dm-chip:hover{background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-faint) 50%,transparent)}}
.dm-fmx-ft{display:flex;align-items:center;justify-content:space-between;gap:var(--dm-s-2);margin-top:var(--dm-s-1)}
.dm-fmx-ms{font-size:12px}
.dm-fmx-done{display:flex;flex-direction:column;align-items:center;gap:6px;padding:var(--dm-s-6) 0;text-align:center;font-size:13px}
.dm-fmx-done>svg{width:28px;height:28px;color:var(--dm-ok)}
form.htmlHTML · 13 lines
<div class="card" data-c="form">
  <div class="stage-in">
    <form class="fmx panel" novalidate>
      <div class="fmx-h"><b>New project brief</b><button type="button" class="pill soft sm fmx-ai"><svg class="i xs"><use href="#i-spark"/></svg>Draft with agent</button></div>
      <label class="fmx-f"><span>Project name<i class="fmx-dr" aria-hidden="true"><svg class="i xs"><use href="#i-spark"/></svg>Drafted</i></span><input name="n" placeholder="Pricing refresh" maxlength="40"><em class="fmx-c num">0/40</em></label>
      <label class="fmx-f"><span>Goal<i class="fmx-dr" aria-hidden="true"><svg class="i xs"><use href="#i-spark"/></svg>Drafted</i></span><textarea name="g" rows="2" placeholder="What should change, and why?"></textarea></label>
      <div class="fmx-f"><span>Type</span><div class="fmx-ty"><button type="button" class="chip sm" data-v="web">Website</button><button type="button" class="chip sm" data-v="app">App</button><button type="button" class="chip sm" data-v="brand">Brand</button></div></div>
      <label class="fmx-f"><span>Due<i class="fmx-dr" aria-hidden="true"><svg class="i xs"><use href="#i-spark"/></svg>Drafted</i></span><input name="d" type="text" placeholder="Nov 14"></label>
      <div class="fmx-ft"><span class="fmx-ms muted"></span><button class="pill primary sm fmx-go" type="submit">Create brief</button></div>
    </form>
  </div>
</div>
form.cssHTML · 27 lines
/* ---------- Form ---------- */
.fmx{padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-3);max-width:380px;margin:0 auto;width:100%}
.fmx-h{display:flex;align-items:center;justify-content:space-between;gap:var(--s-2)}
.fmx-h b{font-weight:500;font-size:14px}
.fmx-f{display:flex;flex-direction:column;gap:5px;font-size:12.5px;font-weight:500;position:relative}
.fmx-f input,.fmx-f textarea{border:0;outline:0;border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);padding:10px var(--s-3);font:inherit;font-size:13.5px;line-height:20px;font-weight:400;color:var(--fg);resize:none;transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.fmx-f :is(input,textarea){corner-shape:squircle;border-radius:var(--r-field-sq)}}
.fmx-f input::placeholder,.fmx-f textarea::placeholder{color:var(--muted)}
.fmx-f input:focus,.fmx-f textarea:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
/* a field marked wrong keeps its red edge, focused or not, until it is fixed; the message says what is wrong */
.fmx-f.bad input,.fmx-f.bad textarea{box-shadow:inset 0 0 0 1px var(--bad)}
.fmx-f .er{font-size:11.5px;color:var(--bad);font-weight:400;animation:enter .35s var(--ease) backwards}
/* what the agent drafted says so quietly beside its label, until someone edits it */
.fmx-f>span:first-child{display:flex;align-items:center;gap:var(--s-2)}
.fmx-dr{display:inline-flex;align-items:center;gap:3px;font-style:normal;font-size:11px;font-weight:400;color:var(--muted);opacity:0;transform:translateX(-3px);transition:opacity var(--t) var(--ease),transform var(--t) var(--ease)}
.fmx-dr .i{color:var(--accent-fg,var(--accent))}
.fmx-f.ai .fmx-dr{opacity:1;transform:none}
.fmx-c{position:absolute;right:12px;top:24px;height:40px;display:flex;align-items:center;font-style:normal;font-size:11px;color:var(--muted);font-weight:400;pointer-events:none}
.fmx-ty{display:flex;gap:6px}
/* the types are white chips with a dimmed grey edge; the picked one's edge is a step stronger */
.fmx-ty .chip{background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--pick-line) 50%,transparent)}
@media (hover:hover) and (pointer:fine){.fmx-ty .chip:hover{background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--faint) 50%,transparent)}}
.fmx-ft{display:flex;align-items:center;justify-content:space-between;gap:var(--s-2);margin-top:var(--s-1)}
.fmx-ms{font-size:12px}
.fmx-done{display:flex;flex-direction:column;align-items:center;gap:6px;padding:var(--s-6) 0;text-align:center;font-size:13px}
.fmx-done>svg{width:28px;height:28px;color:var(--ok)}
form.jsHTML · 30 lines
DM.def('form', (r, c) => {
  r.innerHTML = r.innerHTML.split('{spark}').join(I('spark','xs'));
  const form = $('.fmx', r), n = $('[name=n]', r), g = $('[name=g]', r), d = $('[name=d]', r), cnt = $('.fmx-c', r), ms = $('.fmx-ms', r);
  let type = null;
  n.oninput = () => { cnt.textContent = n.value.length + '/40'; clear(n); };
  g.oninput = () => clear(g); d.oninput = () => clear(d);
  const chips = $$('.fmx-ty .chip', r);
  chips.forEach(ch => ch.onclick = () => { type = ch.dataset.v; chips.forEach(x => x.classList.toggle('on', x === ch)); clear(ch.parentElement); });
  function clear(el){ const f = el.closest('.fmx-f'); f.classList.remove('bad', 'ai'); $('.er', f)?.remove(); }
  function bad(el, t){ const f = el.closest('.fmx-f'); if (f.classList.contains('bad')) return; f.classList.add('bad'); f.insertAdjacentHTML('beforeend', `<span class="er">${t}</span>`); }
  async function type_(el, s){ el.closest('.fmx-f').classList.add('ai'); el.value = ''; for (const ch of s){ el.value += ch; el.dispatchEvent(new Event('input')); el.closest('.fmx-f').classList.add('ai'); await c.wait(18); } }
  $('.fmx-ai', r).onclick = () => c.run(async () => {
    const b = $('.fmx-ai', r); b.innerHTML = '<span class="think on"><i></i><i></i><i></i><i></i></span>Drafting';
    if (!n.value) await type_(n, 'Pricing refresh');
    await type_(g, 'Lift trial to paid by showing annual plans first and keeping one CTA per card.');
    if (!type) chips[0].click();
    if (!d.value) await type_(d, 'Nov 14');
    b.innerHTML = I('check','xs') + 'Drafted'; swap(ms, 'Review what the agent drafted');
  });
  // the preview has the agent fill it in once on its own, unless you take over first
  c.auto(1400, () => { if (!n.value) $('.fmx-ai', r).click(); });
  form.onsubmit = e => { e.preventDefault(); let ok = true;
    if (!n.value.trim()){ bad(n, 'Give it a name'); ok = false; }
    if (g.value.trim().length < 12){ bad(g, 'Add a sentence about the goal'); ok = false; }
    if (!type){ bad(chips[0], 'Pick a type'); ok = false; }
    if (!ok) return swap(ms, 'A few fields need a look');
    c.run(async () => { const go = $('.fmx-go', r); go.innerHTML = '<span class="spin"></span>'; await c.wait(800);
      hold(form); [...form.children].forEach(k => k.hidden = true); const dn = html(`<div class="fmx-done">${I('ok')}<b>${n.value} created</b><span class="muted">The agent is gathering references now</span></div>`); form.appendChild(dn); stagger([...dn.children], 70); }); };
});
06

Questions

What people ask about it.

How does the agent fill in the fields?
Control the form with values and onValuesChange, and update the values inside your onDraft promise. Any text field that changes while the draft runs gets the Drafted mark.
Can I start the agent's draft without a click?
Yes. Put a ref on the form and call ref.current.draft(). It runs onDraft with the same Drafting and Drafted states as the button.
How do I validate a field against another field?
validate receives the field's value and all the form's values. Return a message string to block the submit, or a falsy value to let it pass.
07

More in Forms and inputs

Buttons, fields, pickers and sign-in flows for any product.

ButtonsEvery button style, with loading, success and hold-to-confirm states.
BadgeSmall labels for plans, environments and status, with a dot, an icon or a remove button.
TooltipQuiet labels on hover, with shortcuts and a richer preview card.
Search inputSearch with scoped filters, recent queries and an agent answer.
Country selectA searchable select with country codes, currency and keyboard support.
Multi-selectA combobox that filters as you type and keeps your picks as chips.
Social sign-inOne-tap providers that remember the last one you used.
Sign inEmail first, then the one-time code from the email, copied or typed in.
All 11 in Forms and inputs →