Form builder
Form builder screens. Fields to add, order and set up, logic for follow-up questions, a live preview on desktop and phone, responses with summary and table, and publishing with link, embed and QR code.
One of 3 components in Automation and builders
Variants
Details
What Form builder brings along, read from the registry and the source.
- Category
- Automation and builders
- Area
- App
- Variants
- 5
- Version
- 1.0.1 · since 1.1.0
- File
- components/ui/form-builder.tsx
- npm packages
- None
- Languages
- English and German, 84 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The FormBuilderProps interface from components/ui/form-builder.tsx, with the comments from the code.
variantoptionalFormBuilderVariantbuilderlogicpreviewresponsespublish
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlerequiredstringsubtitleoptionalstringformTitleoptionalstringTitle and intro shown on the form itself
formIntrooptionalstringfieldsoptionalFormField[]logicoptionalLogicRule[]responsesoptionalFormResponse[]urloptionalstringpublish: the public address of the form
nowoptionalstringISO date and time that counts as now
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on save, publish and submit. Without it the block simulates the request.
Built-in texts
Form builder writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 84 texts
| Key | English | German |
|---|---|---|
| text | English Short text | German Kurzer Text |
| English Email | German E-Mail | |
| long | English Long text | German Langer Text |
| choice | English Single choice | German Einfachauswahl |
| checkbox | English Multiple choice | German Mehrfachauswahl |
| rating | English Rating | German Bewertung |
| date | English Date | German Datum |
| number | English Number | German Zahl |
| fields | English Fields | German Felder |
| addField | English Add field | German Feld hinzufügen |
| addType | English Add {type} | German {type} hinzufügen |
| newField | English New question | German Neue Frage |
| moveUp | English Move {name} up | German {name} nach oben |
| moveDown | English Move {name} down | German {name} nach unten |
| duplicate | English Duplicate {name} | German {name} duplizieren |
| remove | English Remove {name} | German {name} entfernen |
| settings | English Field settings | German Feldeinstellungen |
| pickField | English Pick a field to change it. | German Wähle ein Feld, um es zu ändern. |
| label | English Question | German Frage |
| help | English Help text | German Hilfetext |
| placeholder | English Placeholder | German Platzhalter |
| required | English Required | German Pflichtfeld |
| requiredText | English The form cannot be sent without an answer. | German Ohne Antwort lässt sich das Formular nicht senden. |
| options | English Options | German Optionen |
| addOption | English Add option | German Option hinzufügen |
| option | English Option {n} | German Option {n} |
| removeOption | English Remove option {n} | German Option {n} entfernen |
| form | English Form | German Formular |
| logic | English Logic | German Logik |
| logicText | English Show questions only when an earlier answer fits. Hidden questions are never required. | German Fragen nur zeigen, wenn eine frühere Antwort passt. Versteckte Fragen sind nie Pflicht. |
| when | English When | German Wenn |
| is | English is | German ist |
| show | English show | German zeige |
| addRule | English Add rule | German Regel hinzufügen |
| removeRule | English Remove rule | German Regel entfernen |
| noLogic | English No rules yet. Every question is shown to everyone. | German Noch keine Regeln. Alle sehen jede Frage. |
| pathFor | English What people see | German Was Leute sehen |
| pathHint | English Pick an answer to see which questions follow. | German Wähle eine Antwort, um zu sehen, welche Fragen folgen. |
| alwaysShown | English Always | German Immer |
| shownIf | English If {field} is {value} | German Wenn {field} {value} ist |
| desktop | English Desktop | German Desktop |
| phone | English Phone | German Telefon |
| device | English Device | German Gerät |
| submit | English Send | German Senden |
| sending | English Sending… | German Wird gesendet… |
| thanks | English Thank you, your answer is in. | German Danke, deine Antwort ist da. |
| thanksText | English We read every answer and get back within two working days. | German Wir lesen jede Antwort und melden uns innerhalb von zwei Werktagen. |
| again | English Fill in again | German Erneut ausfüllen |
| requiredError | English Please answer this question. | German Bitte beantworte diese Frage. |
| emailError | English Enter an email address like name@example.com. | German Gib eine E-Mail-Adresse wie name@beispiel.de ein. |
| responses | English Responses | German Antworten |
| answersN | English {n} answers | German {n} Antworten |
| total | English Responses | German Antworten |
| today | English Today | German Heute |
| completion | English Finished | German Abgeschlossen |
| avgRating | English Average rating | German Durchschnitt |
| summary | English Summary | German Übersicht |
| table | English All answers | German Alle Antworten |
| download | English Download CSV | German CSV herunterladen |
| search | English Search answers | German Antworten suchen |
| noAnswer | English · | German · |
| publish | English Publish | German Veröffentlichen |
| published | English Live | German Live |
| draft | English Draft | German Entwurf |
| link | English Link | German Link |
| copy | English Copy | German Kopieren |
| copied | English Copied | German Kopiert |
| embed | English Embed | German Einbetten |
| embedText | English Paste this code where the form should appear. | German Füge diesen Code dort ein, wo das Formular erscheinen soll. |
| qr | English QR code | German QR-Code |
| qrText | English For posters, menus and counters. | German Für Aushänge, Speisekarten und Theken. |
| accept | English Accept responses | German Antworten annehmen |
| acceptText | English Turn off to close the form, the link shows a short note. | German Aus schließt das Formular, der Link zeigt einen kurzen Hinweis. |
| closeOn | English Close on a date | German An einem Datum schließen |
| limit | English Limit responses | German Antworten begrenzen |
| limitN | English Up to {n} responses | German Bis zu {n} Antworten |
| notify | English Email me for every response | German Bei jeder Antwort per E-Mail benachrichtigen |
| goLive | English Publish form | German Formular veröffentlichen |
| going | English Publishing… | German Wird veröffentlicht… |
| live | English The form is live. Share the link to start collecting answers. | German Das Formular ist live. Teile den Link, um Antworten zu sammeln. |
| options_ | English Settings | German Einstellungen |
| saveLogic | English Save logic | German Logik speichern |
| logicSaved | English Logic saved | German Logik gespeichert |
| time | English Time | German Zeit |
Accessibility
Read from the source of the component.
- Form builder handles the keys Enter, Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-current, aria-describedby, aria-invalid, aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
- Uses the roles alert, group, radio, radiogroup and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 1,285 lines
'use client';
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Switch } from '@/components/ui/switch';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
import { useScrollFade } from '@/components/ui/motion';
export type FormBuilderVariant = 'builder' | 'logic' | 'preview' | 'responses' | 'publish';
export type FieldType = 'text' | 'email' | 'long' | 'choice' | 'checkbox' | 'rating' | 'date' | 'number';
export interface FormField {
id: string;
type: FieldType;
label: string;
help?: string;
required?: boolean;
placeholder?: string;
/** choice, checkbox */
options?: string[];
}
export interface LogicRule {
id: string;
/** Field id that decides */
when: string;
/** Answer that triggers it */
equals: string;
/** Field ids that show up */
show: string[];
}
export interface FormResponse {
id: string;
/** ISO date and time */
time: string;
/** Answer per field id */
answers: Record<string, string>;
}
export interface FormBuilderProps {
variant?: FormBuilderVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title: string;
subtitle?: string;
/** Title and intro shown on the form itself */
formTitle?: string;
formIntro?: string;
fields?: FormField[];
logic?: LogicRule[];
responses?: FormResponse[];
/** publish: the public address of the form */
url?: string;
/** ISO date and time that counts as now */
now?: string;
/** Called on save, publish and submit. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
text: 'Short text',
email: 'Email',
long: 'Long text',
choice: 'Single choice',
checkbox: 'Multiple choice',
rating: 'Rating',
date: 'Date',
number: 'Number',
fields: 'Fields',
addField: 'Add field',
addType: 'Add {type}',
newField: 'New question',
moveUp: 'Move {name} up',
moveDown: 'Move {name} down',
duplicate: 'Duplicate {name}',
remove: 'Remove {name}',
settings: 'Field settings',
pickField: 'Pick a field to change it.',
label: 'Question',
help: 'Help text',
placeholder: 'Placeholder',
required: 'Required',
requiredText: 'The form cannot be sent without an answer.',
options: 'Options',
addOption: 'Add option',
option: 'Option {n}',
removeOption: 'Remove option {n}',
form: 'Form',
logic: 'Logic',
logicText: 'Show questions only when an earlier answer fits. Hidden questions are never required.',
when: 'When',
is: 'is',
show: 'show',
addRule: 'Add rule',
removeRule: 'Remove rule',
noLogic: 'No rules yet. Every question is shown to everyone.',
pathFor: 'What people see',
pathHint: 'Pick an answer to see which questions follow.',
alwaysShown: 'Always',
shownIf: 'If {field} is {value}',
desktop: 'Desktop',
phone: 'Phone',
device: 'Device',
submit: 'Send',
sending: 'Sending…',
thanks: 'Thank you, your answer is in.',
thanksText: 'We read every answer and get back within two working days.',
again: 'Fill in again',
requiredError: 'Please answer this question.',
emailError: 'Enter an email address like name@example.com.',
responses: 'Responses',
answersN: '{n} answers',
total: 'Responses',
today: 'Today',
completion: 'Finished',
avgRating: 'Average rating',
summary: 'Summary',
table: 'All answers',
download: 'Download CSV',
search: 'Search answers',
noAnswer: '·',
publish: 'Publish',
published: 'Live',
draft: 'Draft',
link: 'Link',
copy: 'Copy',
copied: 'Copied',
embed: 'Embed',
embedText: 'Paste this code where the form should appear.',
qr: 'QR code',
qrText: 'For posters, menus and counters.',
accept: 'Accept responses',
acceptText: 'Turn off to close the form, the link shows a short note.',
closeOn: 'Close on a date',
limit: 'Limit responses',
limitN: 'Up to {n} responses',
notify: 'Email me for every response',
goLive: 'Publish form',
going: 'Publishing…',
live: 'The form is live. Share the link to start collecting answers.',
options_: 'Settings',
saveLogic: 'Save logic',
logicSaved: 'Logic saved',
time: 'Time',
},
de: {
text: 'Kurzer Text',
email: 'E-Mail',
long: 'Langer Text',
choice: 'Einfachauswahl',
checkbox: 'Mehrfachauswahl',
rating: 'Bewertung',
date: 'Datum',
number: 'Zahl',
fields: 'Felder',
addField: 'Feld hinzufügen',
addType: '{type} hinzufügen',
newField: 'Neue Frage',
moveUp: '{name} nach oben',
moveDown: '{name} nach unten',
duplicate: '{name} duplizieren',
remove: '{name} entfernen',
settings: 'Feldeinstellungen',
pickField: 'Wähle ein Feld, um es zu ändern.',
label: 'Frage',
help: 'Hilfetext',
placeholder: 'Platzhalter',
required: 'Pflichtfeld',
requiredText: 'Ohne Antwort lässt sich das Formular nicht senden.',
options: 'Optionen',
addOption: 'Option hinzufügen',
option: 'Option {n}',
removeOption: 'Option {n} entfernen',
form: 'Formular',
logic: 'Logik',
logicText: 'Fragen nur zeigen, wenn eine frühere Antwort passt. Versteckte Fragen sind nie Pflicht.',
when: 'Wenn',
is: 'ist',
show: 'zeige',
addRule: 'Regel hinzufügen',
removeRule: 'Regel entfernen',
noLogic: 'Noch keine Regeln. Alle sehen jede Frage.',
pathFor: 'Was Leute sehen',
pathHint: 'Wähle eine Antwort, um zu sehen, welche Fragen folgen.',
alwaysShown: 'Immer',
shownIf: 'Wenn {field} {value} ist',
desktop: 'Desktop',
phone: 'Telefon',
device: 'Gerät',
submit: 'Senden',
sending: 'Wird gesendet…',
thanks: 'Danke, deine Antwort ist da.',
thanksText: 'Wir lesen jede Antwort und melden uns innerhalb von zwei Werktagen.',
again: 'Erneut ausfüllen',
requiredError: 'Bitte beantworte diese Frage.',
emailError: 'Gib eine E-Mail-Adresse wie name@beispiel.de ein.',
responses: 'Antworten',
answersN: '{n} Antworten',
total: 'Antworten',
today: 'Heute',
completion: 'Abgeschlossen',
avgRating: 'Durchschnitt',
summary: 'Übersicht',
table: 'Alle Antworten',
download: 'CSV herunterladen',
search: 'Antworten suchen',
noAnswer: '·',
publish: 'Veröffentlichen',
published: 'Live',
draft: 'Entwurf',
link: 'Link',
copy: 'Kopieren',
copied: 'Kopiert',
embed: 'Einbetten',
embedText: 'Füge diesen Code dort ein, wo das Formular erscheinen soll.',
qr: 'QR-Code',
qrText: 'Für Aushänge, Speisekarten und Theken.',
accept: 'Antworten annehmen',
acceptText: 'Aus schließt das Formular, der Link zeigt einen kurzen Hinweis.',
closeOn: 'An einem Datum schließen',
limit: 'Antworten begrenzen',
limitN: 'Bis zu {n} Antworten',
notify: 'Bei jeder Antwort per E-Mail benachrichtigen',
goLive: 'Formular veröffentlichen',
going: 'Wird veröffentlicht…',
live: 'Das Formular ist live. Teile den Link, um Antworten zu sammeln.',
options_: 'Einstellungen',
saveLogic: 'Logik speichern',
logicSaved: 'Logik gespeichert',
time: 'Zeit',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion of the app screens */
/* ------------------------------------------------------------------ */
/*
* The same small set in every app block (auth, dashboard, files, inbox …),
* kept in the block so it ships on its own:
* useEnter entrance that waits until its area scrolls in. The area gets
* data-kitm-scope and data-kitm={state}, children take kitm-wipe
* (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
* kitm-up and kitm-pop, with --kitm-d as delay.
* useGlide a marker inside the active item ([data-glide]) glides over
* from where the previous one stood: tabs, segments, steps.
* kitm-in, kitm-fly, kitm-tick for things that appear after an action.
* The server renders the end state, without JavaScript nothing waits longer
* than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
* clip-path change, so no layout shifts.
*/
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
['grow-x', 'transform:scaleX(0)', 900],
['grow-y', 'transform:scaleY(0)', 800],
['up', 'opacity:0;transform:translateY(10px)', 600],
['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
'@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
'.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
'@media (prefers-reduced-motion:no-preference){' +
KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
'[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
'}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
'}';
function KitmStyle() {
// React hoists it once into the head, however many app blocks render
return (
<style href="kitm" precedence="default">
{KITM_CSS}
</style>
);
}
/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
setState('wait');
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
setState('play');
io.disconnect();
},
{ rootMargin: '0px 0px -8% 0px' },
);
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}
type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
const r = el.getBoundingClientRect();
const b = root.getBoundingClientRect();
return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};
/**
* The marker ([data-glide]) is rendered inside the active item, so the server
* and a page without JavaScript show it in place. After a change it starts
* where the old one stood and glides into the new item.
*/
function useGlide<T extends HTMLElement>(key: unknown) {
const ref = useRef<T>(null);
const before = useRef<Box | null>(null);
// Read during render, while the DOM still shows the old marker
if (typeof window !== 'undefined' && ref.current) {
const m = ref.current.querySelector('[data-glide]');
before.current = m ? boxOf(m, ref.current) : null;
}
useIso(() => {
const root = ref.current;
const m = root?.querySelector<HTMLElement>('[data-glide]');
const a = before.current;
if (!root || !m || !a || prefersReducedMotion()) return;
const b = boxOf(m, root);
if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
}, [key]);
return ref;
}
/* ------------------------------------------------------------------ */
/* Parts */
/* ------------------------------------------------------------------ */
type P = Omit<FormBuilderProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; locale: Locale; fields: FormField[]; now: string };
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: FormBuilderProps['onAction'], action: string, data: Record<string, unknown>, ms = 700) {
if (onAction) await onAction(action, data);
else await wait(prefersReducedMotion() ? 0 : ms);
}
const TYPE_ICON: Record<FieldType, IconName> = { text: 'type', email: 'at-sign', long: 'align-left', choice: 'circle', checkbox: 'check-square', rating: 'star', date: 'calendar', number: 'hash' };
const TYPES: FieldType[] = ['text', 'email', 'long', 'choice', 'checkbox', 'rating', 'date', 'number'];
const control = 'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-10 sm:text-[14px]';
function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
<h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Card({ title, description, action, children, className, bodyClassName }: { title?: React.ReactNode; description?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string }) {
return (
<section className={cn('flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>
{(title || action) && (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
<div className="min-w-0">
{title && <h2 className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">{title}</h2>}
{description && <p className="mt-0.5 text-pretty text-[13px] text-muted-foreground">{description}</p>}
</div>
{action}
</div>
)}
<div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
</section>
);
}
function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const ref = useGlide<HTMLDivElement>(value);
const onKey = (e: React.KeyboardEvent, i: number) => {
const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!dir) return;
e.preventDefault();
const next = (i + dir + options.length) % options.length;
onChange(options[next].value);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
};
return (
<div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-muted p-1', className)}>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={value === o.value ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => onKey(e, i)}
className={cn(
'relative inline-flex h-11 min-w-11 sm:h-10 sm:min-w-0 flex-1 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:h-8',
value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
>
{value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
<span className="relative inline-flex items-center gap-1.5">{o.label}</span>
</button>
))}
</div>
);
}
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
/** One field as the person filling in sees it. `value` and `onChange` make it live, without them it is a still preview. */
function FieldView({ f, t, value = '', onChange, error, idBase }: { f: FormField; t: Texts; value?: string; onChange?: (v: string) => void; error?: string; idBase: string }) {
const id = `${idBase}-${f.id}`;
const still = !onChange;
const label = (
<span className="flex items-baseline gap-1 text-[14px] font-medium text-foreground">
{f.label}
{f.required && <span aria-hidden className="text-destructive">*</span>}
</span>
);
const help = f.help && <span className="mt-0.5 block text-[12.5px] text-muted-foreground">{f.help}</span>;
const err = error && (
<span role="alert" className="kitm-in mt-1.5 flex items-center gap-1.5 text-[12.5px] font-medium text-destructive">
<Icon name="alert-circle" size={14} />
{error}
</span>
);
const ring = error ? 'border-[color-mix(in_oklab,var(--destructive)_70%,var(--border))]' : '';
if (f.type === 'choice' || f.type === 'checkbox') {
const picked = value ? value.split('|') : [];
return (
<fieldset aria-describedby={error ? `${id}-e` : undefined}>
<legend>{label}</legend>
{help}
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{(f.options ?? []).map((o) => {
const on = picked.includes(o);
return (
<label key={o} className={cn('relative flex min-h-11 items-center gap-3 rounded-lg border px-3 py-2 text-[14px] transition-[border-color,background-color] duration-200', still ? 'cursor-default' : 'cursor-pointer', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]' : 'border-border hover:border-border-strong')}>
<input type={f.type === 'choice' ? 'radio' : 'checkbox'} name={id} disabled={still} checked={on} onChange={() => onChange?.(f.type === 'choice' ? o : (on ? picked.filter((x) => x !== o) : [...picked, o]).join('|'))} className="peer sr-only" />
<span aria-hidden className={cn('grid size-[18px] shrink-0 place-items-center border transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]', f.type === 'choice' ? 'rounded-full' : 'rounded-[5px]', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
{on && (f.type === 'choice' ? <span className="size-[7px] rounded-full bg-primary-foreground" /> : <Icon name="check" size={12} strokeWidth={2.6} />)}
</span>
<span className="text-foreground">{o}</span>
</label>
);
})}
</div>
{err}
</fieldset>
);
}
if (f.type === 'rating') {
const n = Number(value) || 0;
return (
<fieldset>
<legend>{label}</legend>
{help}
<div className="mt-2 flex gap-1.5" role="radiogroup" aria-label={f.label}>
{[1, 2, 3, 4, 5].map((k) => (
<button key={k} type="button" role="radio" aria-checked={n === k} aria-label={`${k} / 5`} disabled={still} onClick={() => onChange?.(String(k))} className={cn('grid size-11 place-items-center rounded-lg border transition-[transform,background-color,border-color] duration-200 active:scale-90', k <= n ? 'border-[color-mix(in_oklab,var(--warning)_55%,var(--border))] bg-[color-mix(in_oklab,var(--warning)_16%,var(--card))] text-warning-foreground' : 'border-border text-subtle-foreground hover:text-foreground', still && 'cursor-default')}>
<Icon name="star" size={18} className={k <= n ? '[&_path]:fill-current' : ''} />
</button>
))}
</div>
{err}
</fieldset>
);
}
const Tag = f.type === 'long' ? 'textarea' : 'input';
return (
<label className="block" htmlFor={id}>
{label}
{help}
<Tag
id={id}
readOnly={still}
tabIndex={still ? -1 : undefined}
type={f.type === 'email' ? 'email' : f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'}
rows={f.type === 'long' ? 4 : undefined}
placeholder={f.placeholder}
value={value}
aria-invalid={Boolean(error)}
onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => onChange?.(e.target.value)}
className={cn(control, 'mt-2', f.type === 'long' && 'h-auto py-2.5 sm:h-auto', ring, still && 'pointer-events-none')}
/>
{err}
</label>
);
}
/* ------------------------------------------------------------------ */
/* builder */
/* ------------------------------------------------------------------ */
function Builder({ t, title, subtitle, fields: start, formTitle, formIntro }: P) {
const id = useId();
const [fields, setFields] = useState(start);
const [sel, setSel] = useState(start[0]?.id ?? '');
const [fresh, setFresh] = useState('');
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const listRef = useRef<HTMLOListElement>(null);
const [rowRef, fade] = useScrollFade<HTMLUListElement>();
const f = fields.find((x) => x.id === sel);
const set = (patch: Partial<FormField>) => setFields((l) => l.map((x) => (x.id === sel ? { ...x, ...patch } : x)));
const add = (type: FieldType) => {
const n: FormField = { id: `f${Date.now()}`, type, label: t.newField, options: type === 'choice' || type === 'checkbox' ? [fill(t.option, { n: 1 }), fill(t.option, { n: 2 })] : undefined };
const at = Math.max(0, fields.findIndex((x) => x.id === sel)) + 1;
setFields((l) => [...l.slice(0, at), n, ...l.slice(at)]);
setSel(n.id);
setFresh(n.id);
setMsg(fill(t.addType, { type: t[type] }));
};
const move = (i: number, d: number) => {
const j = i + d;
if (j < 0 || j >= fields.length) return;
setFields((l) => {
const c = [...l];
[c[i], c[j]] = [c[j], c[i]];
return c;
});
};
// Fields glide to their new place when moved
const before = useRef<Map<string, number>>(new Map());
if (typeof window !== 'undefined' && listRef.current) before.current = new Map(Array.from(listRef.current.children, (el) => [(el as HTMLElement).dataset.id ?? '', el.getBoundingClientRect().top]));
useIso(() => {
if (!listRef.current || prefersReducedMotion()) return;
Array.from(listRef.current.children).forEach((el) => {
const a = before.current.get((el as HTMLElement).dataset.id ?? '');
const b = el.getBoundingClientRect().top;
if (a !== undefined && Math.abs(a - b) > 1) el.animate([{ transform: `translateY(${a - b}px)` }, { transform: 'none' }], { duration: 420, easing: KITM_EASE });
});
}, [fields.map((x) => x.id).join()]);
const settings = f && (
<div key={f.id} className="kitm-in grid gap-4">
<p className="flex items-center gap-2 text-[12.5px] font-medium text-muted-foreground">
<Icon name={TYPE_ICON[f.type]} size={14} />
{t[f.type]}
</p>
<label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-l`}>
{t.label}
<input id={`${id}-l`} value={f.label} onChange={(e) => set({ label: e.target.value })} className={control} />
</label>
<label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-h`}>
{t.help}
<input id={`${id}-h`} value={f.help ?? ''} onChange={(e) => set({ help: e.target.value })} className={control} />
</label>
{(f.type === 'text' || f.type === 'email' || f.type === 'long' || f.type === 'number') && (
<label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-p`}>
{t.placeholder}
<input id={`${id}-p`} value={f.placeholder ?? ''} onChange={(e) => set({ placeholder: e.target.value })} className={control} />
</label>
)}
{f.options && (
<div>
<p className="text-[13px] font-medium text-foreground">{t.options}</p>
<ul className="mt-1.5 grid gap-1.5">
{f.options.map((o, i) => (
<li key={i} className="kitm-in flex items-center gap-2">
<span aria-hidden className={cn('size-3.5 shrink-0 border border-border-strong', f.type === 'choice' ? 'rounded-full' : 'rounded-[4px]')} />
<input aria-label={fill(t.option, { n: i + 1 })} value={o} onChange={(e) => set({ options: f.options!.map((x, k) => (k === i ? e.target.value : x)) })} className={cn(control, 'sm:h-9')} />
<button type="button" aria-label={fill(t.removeOption, { n: i + 1 })} disabled={f.options!.length < 3} onClick={() => set({ options: f.options!.filter((_, k) => k !== i) })} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive disabled:opacity-30 sm:size-9">
<Icon name="x" size={15} />
</button>
</li>
))}
</ul>
<Button variant="ghost" size="sm" iconLeft="plus" className="mt-1" onClick={() => set({ options: [...f.options!, fill(t.option, { n: f.options!.length + 1 })] })}>
{t.addOption}
</Button>
</div>
)}
<Switch checked={Boolean(f.required)} onCheckedChange={(v) => set({ required: v })} label={t.required} description={t.requiredText} className="border-t border-border pt-4" />
</div>
);
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 md:grid-cols-[minmax(0,1fr)_17rem] xl:grid-cols-[13rem_minmax(0,1fr)_19rem] lg:items-start">
{/* Wide screens: the field types as a column. Below 1280 px, where the app sidebar takes room, a row of chips above the form. */}
<nav aria-label={t.addField} className="kitm-up md:col-span-2 xl:col-span-1 xl:sticky xl:top-6">
<p className="mb-2 text-[12.5px] font-medium text-muted-foreground">{t.addField}</p>
<ul ref={rowRef} className={cn('kit-no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 xl:mx-0 xl:grid xl:gap-1 xl:overflow-visible xl:px-0 xl:[mask-image:none]', fade)}>
{TYPES.map((ty) => (
<li key={ty} className="shrink-0">
<button type="button" aria-label={fill(t.addType, { type: t[ty] })} onClick={() => add(ty)} className="group flex h-11 w-full items-center gap-2.5 whitespace-nowrap rounded-lg border border-border bg-card px-3 text-[13px] font-medium text-foreground shadow-[var(--shadow-sm)] transition-[border-color,transform] duration-200 hover:border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] active:scale-[0.98] xl:h-10">
<Icon name={TYPE_ICON[ty]} size={15} className="shrink-0 text-muted-foreground transition-colors group-hover:text-primary" />
{t[ty]}
<Icon name="plus" size={14} className="ms-auto hidden shrink-0 text-subtle-foreground xl:block" />
</button>
</li>
))}
</ul>
</nav>
{/* The form as it will look, each field a block that can be picked */}
<section aria-label={t.form} className="kitm-up [--kitm-d:80ms] min-w-0 rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
<header className="border-b border-border px-5 py-5 sm:px-7">
<h2 className="text-[20px] font-semibold tracking-[-0.02em] text-foreground">{formTitle}</h2>
{formIntro && <p className="mt-1 text-pretty text-[14px] text-muted-foreground">{formIntro}</p>}
</header>
<ol ref={listRef} className="grid gap-2 p-3 sm:p-4">
{fields.map((x, i) => (
<li key={x.id} data-id={x.id} className={cn(x.id === fresh && 'kitm-pop')}>
<div
role="group"
tabIndex={0}
aria-label={`${t[x.type]}: ${x.label}`}
aria-current={x.id === sel ? 'true' : undefined}
onClick={() => setSel(x.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSel(x.id);
} else if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
move(i, e.key === 'ArrowUp' ? -1 : 1);
}
}}
className={cn('group relative rounded-lg border p-4 outline-none transition-[border-color,background-color,box-shadow] duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:p-5', x.id === sel ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_4%,var(--card))]' : 'cursor-pointer border-transparent hover:border-border hover:bg-[color-mix(in_oklab,var(--foreground)_2%,transparent)]')}
>
{x.id === sel && <span aria-hidden className="absolute inset-y-3 start-0 w-0.5 rounded-full bg-primary" />}
<FieldView f={x} t={t} idBase={`${id}-b`} />
<div className={cn('kitm-in mt-3 items-center justify-end gap-0.5 [--kitm-t:.3s]', x.id === sel ? 'flex' : 'hidden max-md:flex')}>
{[
{ icon: 'arrow-up' as IconName, label: fill(t.moveUp, { name: x.label }), on: () => move(i, -1), off: i === 0 },
{ icon: 'arrow-down' as IconName, label: fill(t.moveDown, { name: x.label }), on: () => move(i, 1), off: i === fields.length - 1 },
{
icon: 'copy' as IconName,
label: fill(t.duplicate, { name: x.label }),
on: () => {
const c = { ...x, id: `f${Date.now()}` };
setFields((l) => [...l.slice(0, i + 1), c, ...l.slice(i + 1)]);
setSel(c.id);
setFresh(c.id);
},
},
{
icon: 'trash' as IconName,
label: fill(t.remove, { name: x.label }),
on: () => {
setFields((l) => l.filter((y) => y.id !== x.id));
setSel(fields[i + 1]?.id ?? fields[i - 1]?.id ?? '');
},
},
].map((b) => (
<button
key={b.icon}
type="button"
aria-label={b.label}
disabled={b.off}
onClick={(e) => {
e.stopPropagation();
b.on();
}}
className={cn('grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-8', b.icon === 'trash' && 'hover:text-destructive')}
>
<Icon name={b.icon} size={15} />
</button>
))}
</div>
{/* Phones: the settings open right under the picked field */}
{x.id === sel && <div className="mt-4 border-t border-border pt-4 md:hidden" onClick={(e) => e.stopPropagation()}>{settings}</div>}
</div>
</li>
))}
</ol>
</section>
<aside aria-label={t.settings} className="kitm-up [--kitm-d:160ms] hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] md:block md:sticky md:top-6">
<h2 className="border-b border-border px-4 py-3.5 text-[14px] font-semibold text-foreground sm:px-5">{t.settings}</h2>
<div className="p-4 sm:p-5">{settings ?? <p className="text-[13px] text-muted-foreground">{t.pickField}</p>}</div>
</aside>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* logic */
/* ------------------------------------------------------------------ */
function Logic({ t, title, subtitle, fields, logic = [], onAction }: P) {
const [rules, setRules] = useState(logic);
const [probe, setProbe] = useState<Record<string, string>>({});
const [saved, setSaved] = useState(false);
const [scope, enter] = useEnter<HTMLDivElement>();
const deciders = fields.filter((f) => f.type === 'choice' || f.type === 'rating');
const label = (fid: string) => fields.find((f) => f.id === fid)?.label ?? fid;
const ruleFor = (fid: string) => rules.find((r) => r.show.includes(fid));
const visible = (fid: string) => {
const r = ruleFor(fid);
return !r || probe[r.when] === r.equals;
};
const set = (rid: string, patch: Partial<LogicRule>) => {
setRules((l) => l.map((r) => (r.id === rid ? { ...r, ...patch } : r)));
setSaved(false);
};
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-start">
<Card title={t.logic} description={t.logicText} className="kitm-up" bodyClassName="p-0 sm:p-0">
<ul className="divide-y divide-border">
{rules.map((r, i) => {
const decider = fields.find((f) => f.id === r.when);
const after = fields.slice(fields.findIndex((f) => f.id === r.when) + 1);
return (
<li key={r.id} className="kitm-up grid gap-3 px-4 py-4 sm:px-5" style={{ '--kitm-d': `${i * 60}ms` } as React.CSSProperties}>
<div className="flex flex-wrap items-center gap-2 text-[13px]">
<span className="font-semibold text-foreground">{t.when}</span>
<span className="relative">
<select aria-label={t.when} value={r.when} onChange={(e) => set(r.id, { when: e.target.value, equals: fields.find((f) => f.id === e.target.value)?.options?.[0] ?? '' })} className={cn(control, 'w-auto max-w-[14rem] appearance-none pe-8 sm:h-9 sm:text-[13px]')}>
{deciders.map((f) => (
<option key={f.id} value={f.id}>
{f.label}
</option>
))}
</select>
<Icon name="chevrons-up-down" size={13} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
<span className="text-muted-foreground">{t.is}</span>
<span className="relative">
<select aria-label={t.is} value={r.equals} onChange={(e) => set(r.id, { equals: e.target.value })} className={cn(control, 'w-auto max-w-[14rem] appearance-none pe-8 sm:h-9 sm:text-[13px]')}>
{(decider?.type === 'rating' ? ['1', '2', '3', '4', '5'] : decider?.options ?? []).map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
<Icon name="chevrons-up-down" size={13} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
<button type="button" aria-label={t.removeRule} onClick={() => setRules((l) => l.filter((x) => x.id !== r.id))} className="ms-auto grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive sm:size-8">
<Icon name="trash" size={15} />
</button>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<span className="me-1 text-[13px] font-semibold text-foreground">{t.show}</span>
{after.map((f) => {
const on = r.show.includes(f.id);
return (
<button key={f.id} type="button" aria-pressed={on} onClick={() => set(r.id, { show: on ? r.show.filter((x) => x !== f.id) : [...r.show, f.id] })} className={cn('inline-flex h-11 items-center sm:h-9 gap-1.5 rounded-lg border px-2.5 text-[12.5px] font-medium transition-colors', on ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_10%,transparent)] text-foreground' : 'border-dashed border-border-strong text-muted-foreground hover:text-foreground')}>
<Icon name={on ? 'check' : 'plus'} size={13} />
{f.label}
</button>
);
})}
</div>
</li>
);
})}
</ul>
{!rules.length && <p className="px-5 py-8 text-center text-[13.5px] text-muted-foreground">{t.noLogic}</p>}
<div className="border-t border-border px-4 py-3 sm:px-5">
<Button variant="ghost" size="sm" iconLeft="plus" disabled={!deciders.length} onClick={() => setRules((l) => [...l, { id: `r${Date.now()}`, when: deciders[0].id, equals: deciders[0].options?.[0] ?? '5', show: [] }])}>
{t.addRule}
</Button>
</div>
</Card>
{/* What a person sees for the answers picked here */}
<Card title={t.pathFor} description={t.pathHint} className="kitm-up [--kitm-d:120ms] lg:sticky lg:top-6" bodyClassName="p-0 sm:p-0">
<ol className="relative">
{fields.map((f, i) => {
const r = ruleFor(f.id);
const on = visible(f.id);
const decides = rules.some((x) => x.when === f.id);
return (
<li key={f.id} className={cn('relative border-b border-border px-4 py-3 transition-[opacity,background-color] duration-300 last:border-b-0 sm:px-5', on ? '' : 'bg-[color-mix(in_oklab,var(--foreground)_2%,transparent)] opacity-45')}>
<p className="flex items-center gap-2 text-[13.5px] font-medium text-foreground">
<span className="grid size-6 shrink-0 place-items-center rounded-md bg-muted text-[11px] font-semibold tabular-nums text-muted-foreground">{i + 1}</span>
<span className="min-w-0 flex-1 truncate">{f.label}</span>
<Icon name={on ? 'eye' : 'eye-off'} size={14} className="shrink-0 text-subtle-foreground" />
</p>
<p className="mt-0.5 ps-8 text-[12px] text-muted-foreground">{r ? fill(t.shownIf, { field: label(r.when), value: r.equals }) : t.alwaysShown}</p>
{decides && (
<div className="mt-2 flex flex-wrap gap-1.5 ps-8">
{(f.type === 'rating' ? ['1', '2', '3', '4', '5'] : f.options ?? []).map((o) => (
<button key={o} type="button" aria-pressed={probe[f.id] === o} onClick={() => setProbe((p) => ({ ...p, [f.id]: p[f.id] === o ? '' : o }))} className={cn('h-11 min-w-11 rounded-md border px-2.5 text-[12px] sm:h-8 sm:min-w-8 sm:px-2 font-medium transition-colors', probe[f.id] === o ? 'border-transparent bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
{o}
</button>
))}
</div>
)}
</li>
);
})}
</ol>
<div className="border-t border-border p-4 sm:p-5">
<Button
className="w-full"
size="sm"
iconLeft={saved ? 'check' : undefined}
onClick={async () => {
await run(onAction, 'save-logic', { rules });
setSaved(true);
}}
>
{saved ? t.logicSaved : t.saveLogic}
</Button>
</div>
</Card>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* preview */
/* ------------------------------------------------------------------ */
function Preview({ t, title, subtitle, fields, logic = [], formTitle, formIntro, onAction }: P) {
const id = useId();
const [device, setDevice] = useState<'desktop' | 'phone'>('desktop');
const [vals, setVals] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [state, setState] = useState<'idle' | 'busy' | 'done'>('idle');
const [scope, enter] = useEnter<HTMLDivElement>();
const shown = fields.filter((f) => {
const r = logic.find((x) => x.show.includes(f.id));
return !r || vals[r.when] === r.equals;
});
const answered = shown.filter((f) => vals[f.id]).length;
const check = () => {
const e: Record<string, string> = {};
for (const f of shown) {
const v = (vals[f.id] ?? '').trim();
if (f.required && !v) e[f.id] = t.requiredError;
else if (f.type === 'email' && v && !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v)) e[f.id] = t.emailError;
}
return e;
};
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
const e = check();
setErrors(e);
const first = Object.keys(e)[0];
if (first) return document.getElementById(`${id}-p-${first}`)?.focus();
setState('busy');
await run(onAction, 'submit', { answers: vals });
setState('done');
};
const form = (
<form noValidate onSubmit={submit} className={cn('mx-auto w-full', device === 'phone' ? 'max-w-none' : 'max-w-[38rem]')}>
{state === 'done' ? (
<div className="kitm-in py-10 text-center">
<span className="mx-auto grid size-14 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground">
<Icon name="check" size={26} strokeWidth={2.4} className="kitm-pop" />
</span>
<h2 className="mt-5 text-[19px] font-semibold tracking-[-0.02em] text-foreground">{t.thanks}</h2>
<p className="mx-auto mt-2 max-w-[26rem] text-pretty text-[14px] text-muted-foreground">{t.thanksText}</p>
<Button
variant="outline"
size="sm"
className="mt-6"
onClick={() => {
setVals({});
setErrors({});
setState('idle');
}}
>
{t.again}
</Button>
</div>
) : (
<>
<h2 className="text-balance text-[22px] font-semibold tracking-[-0.02em] text-foreground">{formTitle}</h2>
{formIntro && <p className="mt-1.5 text-pretty text-[14px] text-muted-foreground">{formIntro}</p>}
<div className="mt-6 grid gap-6">
{shown.map((f) => (
<div key={f.id} className="kitm-in">
<FieldView
f={f}
t={t}
idBase={`${id}-p`}
value={vals[f.id] ?? ''}
error={errors[f.id]}
onChange={(v) => {
setVals((x) => ({ ...x, [f.id]: v }));
if (errors[f.id]) setErrors((x) => ({ ...x, [f.id]: '' }));
}}
/>
</div>
))}
</div>
<div className="mt-8 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* A line that fills as questions get answered */}
<span className="flex items-center gap-3 text-[12.5px] tabular-nums text-muted-foreground">
<span aria-hidden className="block h-1.5 w-full overflow-hidden rounded-full bg-track sm:w-32">
<span className="block h-full origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)]" style={{ transform: `scaleX(${round(shown.length ? answered / shown.length : 0, 3)})` }} />
</span>
<span className="shrink-0">
{answered} / {shown.length}
</span>
</span>
<Button type="submit" disabled={state === 'busy'} iconLeft={state === 'busy' ? 'loader' : undefined} icon={state === 'busy' ? undefined : 'arrow-right'}>
{state === 'busy' ? t.sending : t.submit}
</Button>
</div>
</>
)}
</form>
);
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Segmented
label={t.device}
value={device}
onChange={setDevice}
className="max-md:hidden"
options={[
{ value: 'desktop', label: <><Icon name="monitor" size={14} />{t.desktop}</> },
{ value: 'phone', label: <><Icon name="phone" size={14} />{t.phone}</> },
]}
/>
</Header>
<div ref={scope} {...enter} className="mt-6">
<div className={cn('kitm-up rounded-xl border border-border bg-surface transition-[padding] duration-500', device === 'phone' ? 'md:px-6 md:py-8' : 'p-0')}>
<div className={cn('mx-auto bg-card transition-[max-width,border-radius] duration-500 ease-[var(--ease-kit)]', device === 'phone' ? 'md:max-w-[24rem] md:rounded-[2rem] md:border-[10px] md:border-bezel md:shadow-[var(--shadow-lg)]' : 'max-w-none rounded-xl')}>
<div className="p-5 sm:p-8">{form}</div>
</div>
</div>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* responses */
/* ------------------------------------------------------------------ */
function Responses({ t, title, subtitle, fields, responses = [], now, locale, onAction }: P) {
const [tab, setTab] = useState<'summary' | 'table'>('summary');
const [q, setQ] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const num = (n: number, d = 0) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: d, minimumFractionDigits: d }).format(n);
const day = now.slice(0, 10);
const todayN = responses.filter((r) => r.time.startsWith(day)).length;
const rating = fields.find((f) => f.type === 'rating');
const avg = rating ? responses.reduce((a, r) => a + (Number(r.answers[rating.id]) || 0), 0) / Math.max(1, responses.filter((r) => r.answers[rating.id]).length) : 0;
const complete = responses.filter((r) => fields.filter((f) => f.required).every((f) => r.answers[f.id])).length;
const kpis = [
{ label: t.total, value: num(responses.length) },
{ label: t.today, value: num(todayN) },
{ label: t.completion, value: `${num(responses.length ? (complete / responses.length) * 100 : 0)} %` },
...(rating ? [{ label: t.avgRating, value: `${num(avg, 1)} / 5` }] : []),
];
const shown = responses.filter((r) => !q || Object.values(r.answers).join(' ').toLowerCase().includes(q.toLowerCase()));
const time = (iso: string) => new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', hour: locale === 'de' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(Date.parse(iso + 'Z')));
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Button size="sm" variant="outline" iconLeft="download" onClick={() => void onAction?.('download', {})}>
{t.download}
</Button>
</Header>
<div ref={scope} {...enter} className="mt-6 grid gap-6">
<dl className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border shadow-[var(--shadow-sm)] lg:grid-cols-4">
{kpis.map((k, i) => (
<div key={k.label} className="bg-card p-4 sm:p-5">
<div className="kitm-up" style={{ '--kitm-d': `${i * 60}ms` } as React.CSSProperties}>
<dt className="text-[12.5px] text-muted-foreground">{k.label}</dt>
<dd className="mt-1 text-[24px] font-semibold tracking-[-0.02em] tabular-nums text-foreground">{k.value}</dd>
</div>
</div>
))}
</dl>
<Segmented
label={t.responses}
value={tab}
onChange={setTab}
className="kitm-up w-full sm:w-auto sm:justify-self-start"
options={[
{ value: 'summary', label: t.summary },
{ value: 'table', label: t.table },
]}
/>
{tab === 'summary' ? (
<div key="s" className="kitm-in grid grid-cols-[minmax(0,1fr)] gap-4 md:grid-cols-2">
{fields
.filter((f) => f.type === 'choice' || f.type === 'checkbox' || f.type === 'rating')
.map((f, fi) => {
const opts = f.type === 'rating' ? ['5', '4', '3', '2', '1'] : f.options ?? [];
const counts = opts.map((o) => responses.filter((r) => (r.answers[f.id] ?? '').split('|').includes(o)).length);
const max = Math.max(1, ...counts);
const totalAns = responses.filter((r) => r.answers[f.id]).length || 1;
return (
<Card key={f.id} title={f.label} description={fill(t.answersN, { n: num(responses.filter((r) => r.answers[f.id]).length) })}>
<ul className="grid gap-2.5">
{opts.map((o, i) => (
<li key={o} className="grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)_3rem] items-center gap-3 text-[13px] sm:grid-cols-[minmax(0,9rem)_minmax(0,1fr)_3rem]">
<span className="flex min-w-0 items-center gap-1 truncate text-foreground">
{f.type === 'rating' ? (
<>
{o}
<Icon name="star" size={13} className="text-warning-foreground [&_path]:fill-current" />
</>
) : (
o
)}
</span>
<span className="h-2.5 overflow-hidden rounded-full bg-track">
<span className={cn('kitm-grow-x block h-full rounded-full', counts[i] === max ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--primary)_45%,var(--muted))]')} style={{ width: `${round((counts[i] / max) * 100)}%`, '--kitm-d': `${fi * 80 + i * 50}ms` } as React.CSSProperties} />
</span>
<span className="text-right tabular-nums text-muted-foreground">{num((counts[i] / totalAns) * 100)} %</span>
</li>
))}
</ul>
</Card>
);
})}
</div>
) : (
<section key="t" className="kitm-in min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
<div className="border-b border-border p-3 sm:p-4">
<label className="relative block sm:max-w-xs">
<span className="sr-only">{t.search}</span>
<Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.search} className={cn(control, 'ps-9')} />
</label>
</div>
{/* Tablets and desktop: a table. Phones: one card per response. */}
<div className="hidden md:block">
<table className="w-full table-fixed text-left text-[13px]">
<thead className="border-b border-border bg-surface text-[12px] text-muted-foreground">
<tr>
<th scope="col" className="w-40 px-4 py-2.5 font-medium">
{t.time}
</th>
{fields.slice(0, 4).map((f) => (
<th key={f.id} scope="col" className="truncate px-3 py-2.5 font-medium">
{f.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{shown.map((r) => (
<tr key={r.id} className="hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]">
<td className="whitespace-nowrap px-4 py-3 tabular-nums text-muted-foreground">{time(r.time)}</td>
{fields.slice(0, 4).map((f) => (
<td key={f.id} className="truncate px-3 py-3 text-foreground">
{(r.answers[f.id] ?? '').replace(/\|/g, ', ') || <span className="text-subtle-foreground">{t.noAnswer}</span>}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<ul className="divide-y divide-border md:hidden">
{shown.map((r) => (
<li key={r.id} className="px-4 py-3.5">
<p className="text-[12px] tabular-nums text-muted-foreground">{time(r.time)}</p>
<dl className="mt-1.5 grid gap-1">
{fields.slice(0, 4).map((f) => (
<div key={f.id} className="grid grid-cols-[7rem_minmax(0,1fr)] gap-2 text-[13px]">
<dt className="truncate text-muted-foreground">{f.label}</dt>
<dd className="truncate text-foreground">{(r.answers[f.id] ?? '').replace(/\|/g, ', ') || t.noAnswer}</dd>
</div>
))}
</dl>
</li>
))}
</ul>
</section>
)}
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* publish */
/* ------------------------------------------------------------------ */
/** A small seeded random generator (mulberry32, public domain) so the drawn code is the same on server and browser */
function seeded(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let x = a;
x = Math.imul(x ^ (x >>> 15), x | 1);
x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
};
}
/** A drawn code in the look of a QR code, for the demo. Pass a real one in production. */
function CodeArt({ text }: { text: string }) {
const n = 25;
const cells = useMemo(() => {
const rnd = seeded([...text].reduce((a, ch) => (a * 31 + ch.charCodeAt(0)) >>> 0, 7));
const out: [number, number][] = [];
const finder = (x: number, y: number) => (x < 8 && y < 8) || (x >= n - 8 && y < 8) || (x < 8 && y >= n - 8);
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) if (!finder(x, y) && rnd() > 0.52) out.push([x, y]);
return out;
}, [text]);
const eye = (x: number, y: number) => (
<g key={`${x}-${y}`}>
<rect x={x} y={y} width={7} height={7} rx={1.8} fill="currentColor" />
<rect x={x + 1} y={y + 1} width={5} height={5} rx={1.2} fill="#fff" />
<rect x={x + 2} y={y + 2} width={3} height={3} rx={0.8} fill="currentColor" />
</g>
);
return (
<svg viewBox={`-1 -1 ${n + 2} ${n + 2}`} className="size-full text-neutral-900" aria-hidden>
<rect x={-1} y={-1} width={n + 2} height={n + 2} rx={2} fill="#fff" />
{cells.map(([x, y]) => (
<rect key={`${x}.${y}`} x={x + 0.08} y={y + 0.08} width={0.84} height={0.84} rx={0.25} fill="currentColor" />
))}
{eye(0, 0)}
{eye(n - 7, 0)}
{eye(0, n - 7)}
</svg>
);
}
function CopyRow({ value, t, multiline }: { value: string; t: Texts; multiline?: boolean }) {
const [done, setDone] = useState(false);
useEffect(() => {
if (!done) return;
const id = window.setTimeout(() => setDone(false), 1600);
return () => window.clearTimeout(id);
}, [done]);
return (
<div className={cn('flex min-w-0 gap-1 rounded-lg border border-border bg-surface p-1.5 ps-3.5', multiline ? 'items-start' : 'items-center')}>
<code className={cn('min-w-0 flex-1 py-1.5 font-[inherit] text-[13px] text-foreground', multiline ? 'whitespace-pre-wrap leading-relaxed [overflow-wrap:anywhere]' : 'truncate')}>{value}</code>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
} catch {
/* The check still shows */
}
setDone(true);
}}
className="inline-flex h-11 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-8"
>
<Icon key={String(done)} name={done ? 'check' : 'copy'} size={14} className={done ? 'kitm-tick text-success-foreground' : 'kitm-in [--kitm-t:.25s]'} />
<span key={`l${done}`} className="kitm-in [--kitm-t:.25s]">
{done ? t.copied : t.copy}
</span>
</button>
</div>
);
}
function Publish({ t, title, subtitle, url = 'https://forms.example.com/f/abc123', onAction }: P) {
const [state, setState] = useState<'draft' | 'busy' | 'live'>('draft');
const [accept, setAccept] = useState(true);
const [close, setClose] = useState(false);
const [limit, setLimit] = useState(false);
const [notify, setNotify] = useState(true);
const [scope, enter] = useEnter<HTMLDivElement>();
const embed = `<iframe src="${url}?embed=1" width="100%" height="640" style="border:0" title="Form"></iframe>`;
return (
<Main>
<Header title={title} subtitle={subtitle}>
<span className={cn('inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-[12.5px] font-medium', state === 'live' ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : 'bg-muted text-muted-foreground')}>
<span aria-hidden className={cn('size-1.5 rounded-full', state === 'live' ? 'bg-success motion-safe:animate-pulse' : 'bg-subtle-foreground')} />
{state === 'live' ? t.published : t.draft}
</span>
</Header>
{state === 'live' && (
<p role="status" className="kitm-in mt-5 flex items-start gap-2.5 rounded-xl border border-[color-mix(in_oklab,var(--success)_40%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,var(--card))] p-4 text-[13.5px] text-foreground">
<Icon name="check-circle" size={17} className="mt-px shrink-0 text-success-foreground" />
{t.live}
</p>
)}
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
<div className="grid min-w-0 gap-6">
<Card title={t.link} className="kitm-up">
<CopyRow value={url} t={t} />
</Card>
<Card title={t.embed} description={t.embedText} className="kitm-up [--kitm-d:80ms]">
<CopyRow value={embed} t={t} multiline />
</Card>
<Card title={t.options_} className="kitm-up [--kitm-d:160ms]" bodyClassName="p-0 sm:p-0">
<ul className="divide-y divide-border">
{[
{ label: t.accept, desc: t.acceptText, on: accept, set: setAccept },
{ label: t.closeOn, desc: '31.12.2026', on: close, set: setClose },
{ label: t.limit, desc: fill(t.limitN, { n: 500 }), on: limit, set: setLimit },
{ label: t.notify, on: notify, set: setNotify },
].map((x) => (
<li key={x.label} className="px-4 py-1 sm:px-5">
<Switch checked={x.on} onCheckedChange={x.set} label={x.label} description={x.desc} className="py-2.5" />
</li>
))}
</ul>
</Card>
</div>
<aside className="kitm-up [--kitm-d:120ms] grid gap-6 lg:sticky lg:top-6">
<Card title={t.qr} description={t.qrText}>
<div className="mx-auto w-40 overflow-hidden rounded-xl border border-border bg-white p-2.5 shadow-[var(--shadow-md)] transition-transform duration-500 ease-[var(--ease-kit)] hover:rotate-[-2deg] hover:scale-[1.03]">
<CodeArt text={url} />
</div>
</Card>
<Button
className="w-full"
disabled={state !== 'draft' || !accept}
iconLeft={state === 'busy' ? 'loader' : state === 'live' ? 'check' : 'rocket'}
onClick={async () => {
setState('busy');
await run(onAction, 'publish', { accept, close, limit, notify });
setState('live');
}}
>
{state === 'busy' ? t.going : state === 'live' ? t.published : t.goLive}
</Button>
</aside>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* FormBuilder */
/* ------------------------------------------------------------------ */
/**
* Form builder screens: fields to add, order and set up, logic that shows
* questions for fitting answers, a live preview on desktop and phone,
* responses with a summary and a table, and publishing with link, embed
* code and QR code.
* <FormBuilder variant="builder" title="Feedback" fields={[{ id, type, label, required, options }]} />
*/
export function FormBuilder({ variant = 'builder', sidebar, labels, className, fields = [], now = '2026-10-19T14:30', ...props }: FormBuilderProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const p: P = { ...props, t, locale, fields, now };
const body = (() => {
switch (variant) {
case 'logic':
return <Logic {...p} />;
case 'preview':
return <Preview {...p} />;
case 'responses':
return <Responses {...p} />;
case 'publish':
return <Publish {...p} />;
default:
return <Builder {...p} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<KitmStyle />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<KitmStyle />
{body}
</Sidebar>
);
}Frequent questions
How do I install Form builder?
With aiw add @ai-website/form-builder. The command puts components/ui/form-builder.tsx into your project, the code is yours from then on. It brings along button, icon, switch and sidebar. It needs no further npm packages.
Which variants does Form builder have?
5 variants, Builder, Logic, Live preview, Responses and Publish. You pick one with the variant prop, for example variant="logic".
Can I translate the texts of Form builder?
Yes. Form builder carries 84 texts of its own in English and German, such as “Short text”, “Email” and “Long text”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Form builder?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Form builder with aiw_components and install it with aiw_add @ai-website/form-builder. Name the variant in your prompt, for example variant="builder", and the assistant writes your content straight into the props.
Can I adjust Form builder without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching FormBuilder code to copy.