Form layouts
Complete forms from the kit fields: contact, job application and settings, with sections, grid and actions.
One of 9 components in Forms and inputs
Details
What Form layouts brings along, read from the registry and the source.
- Category
- Forms and inputs
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/form-layout.tsx
- Brings along
- utilskit
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The FormSectionProps interface from components/ui/form-layout.tsx, with the comments from the code.
titlerequiredReact.ReactNodedescriptionoptionalReact.ReactNodelayoutoptional'side' | 'top'side: title in a column on the left from desktop up. top: title above the fields.
childrenrequiredReact.ReactNode
Accessibility
Read from the source of the component.
- Uses the role alert.
- Announces changes through a live region without moving focus.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- With reduced motion (prefers-reduced-motion) it stays still.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 112 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView } from '@/lib/kit';
/*
* The frame of a form: sections with a title on the side (settings) or on
* top (contact, application), a responsive grid for fields and a footer for
* the actions. Fields come from input, select, textarea and friends.
*/
/**
* Lets the direct children rise one after another once the group scrolls
* into view (kit-stagger from kit.css). Server-rendered visible, so nothing
* hides without JavaScript or with reduced motion. After the entrance the
* delays go, so focus and hover react at once.
*/
function useRise(step = 60) {
const [ref, inView] = useInView<HTMLDivElement>();
const [done, setDone] = useState(false);
useEffect(() => {
if (!inView) return;
const t = window.setTimeout(() => setDone(true), step * 8 + 800);
return () => window.clearTimeout(t);
}, [inView, step]);
return { ref, className: cn('kit-stagger', !inView && 'kit-stagger-wait', done && 'kit-stagger-done'), style: { '--kit-step': `${step}ms` } as React.CSSProperties };
}
export interface FormSectionProps {
title: React.ReactNode;
description?: React.ReactNode;
/** side: title in a column on the left from desktop up. top: title above the fields. */
layout?: 'side' | 'top';
className?: string;
children: React.ReactNode;
}
/** A group of fields with a heading. Several sections are divided by hairlines. */
export function FormSection({ title, description, layout = 'top', className, children }: FormSectionProps) {
const head = useRise(80);
if (layout === 'side') {
return (
<section className={cn('grid gap-x-12 gap-y-6 py-8 first:pt-0 last:pb-0 lg:grid-cols-[minmax(0,17rem)_minmax(0,1fr)] [&+&]:border-t [&+&]:border-border', className)}>
<div ref={head.ref} className={head.className} style={head.style}>
<h3 className="text-[15px] font-semibold tracking-[-0.01em] text-foreground">{title}</h3>
{description && <p className="mt-1.5 max-w-[34ch] text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{description}</p>}
</div>
<div className="min-w-0">{children}</div>
</section>
);
}
return (
<section className={cn('py-7 first:pt-0 last:pb-0 [&+&]:border-t [&+&]:border-border', className)}>
<div ref={head.ref} className={head.className} style={head.style}>
<h3 className="text-[15px] font-semibold tracking-[-0.01em] text-foreground">{title}</h3>
{description && <p className="mt-1 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{description}</p>}
</div>
<div className="mt-5">{children}</div>
</section>
);
}
/** Fields side by side from tablet up. Use span={2} on wide fields (col-span-full). The fields rise one after another as the form scrolls in. */
export function FormGrid({ columns = 2, className, children }: { columns?: 1 | 2 | 3; className?: string; children: React.ReactNode }) {
const rise = useRise(60);
return (
<div ref={rise.ref} style={rise.style} className={cn('grid gap-x-5 gap-y-6', columns === 2 && 'sm:grid-cols-2', columns === 3 && 'sm:grid-cols-2 lg:grid-cols-3', rise.className, className)}>
{children}
</div>
);
}
/** The action row: secondary actions left, the main one right. Stacks with the main action on top on phones. */
export function FormActions({ note, divider = true, className, children }: { note?: React.ReactNode; divider?: boolean; className?: string; children: React.ReactNode }) {
return (
// Right after a section its bottom padding is the gap, no extra margin on top
<div className={cn('mt-8 flex flex-col-reverse gap-4 sm:flex-row sm:items-center sm:justify-between [section+&]:mt-0', divider && 'border-t border-border pt-6', className)}>
{note ? <p className="text-pretty text-[13px] leading-relaxed text-muted-foreground sm:max-w-[26rem]">{note}</p> : <span className="max-sm:hidden" />}
<div className="flex flex-col-reverse gap-2.5 sm:flex-row sm:items-center [&>*]:max-sm:w-full">{children}</div>
</div>
);
}
/** A summary of all errors on top of a long form, linking to each field. Put it where focus goes after a failed submit. */
export function FormErrorSummary({ title, errors, className }: { title: React.ReactNode; errors: { field: string; message: React.ReactNode }[]; className?: string }) {
// A new set of errors (after a failed submit) nudges the box once, so the eye finds it
const ref = useRef<HTMLDivElement>(null);
const key = errors.map((e) => e.field).join('|');
const last = useRef(key);
useEffect(() => {
if (last.current === key) return;
last.current = key;
const el = ref.current;
if (!el || !key || prefersReducedMotion() || typeof el.animate !== 'function') return;
el.animate([{ transform: 'translateX(0)' }, { transform: 'translateX(-6px)' }, { transform: 'translateX(5px)' }, { transform: 'translateX(-3px)' }, { transform: 'translateX(0)' }], { duration: 420, easing: 'ease-out' });
}, [key]);
if (!errors.length) return null;
return (
<div ref={ref} role="alert" tabIndex={-1} className={cn('rounded-xl border border-[color-mix(in_oklab,var(--destructive)_40%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_6%,var(--card))] p-4', className)}>
<p className="text-sm font-semibold text-destructive">{title}</p>
<ul className="mt-2 grid gap-1 text-[13px] text-foreground">
{errors.map((e) => (
<li key={e.field} className="flex gap-2 before:mt-[7px] before:size-1 before:shrink-0 before:rounded-full before:bg-destructive before:content-['']">
<a href={`#${e.field}`} className="underline decoration-[color-mix(in_oklab,var(--destructive)_40%,transparent)] underline-offset-4 hover:decoration-destructive">
{e.message}
</a>
</li>
))}
</ul>
</div>
);
}Frequent questions
How do I install Form layouts?
With aiw add @ai-website/form-layout. The command puts components/ui/form-layout.tsx into your project, the code is yours from then on. It needs no further npm packages.
Which variants does Form layouts have?
3 variants, Contact, Application and Settings.
Can an AI assistant build with Form layouts?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Form layouts with aiw_components and install it with aiw_add @ai-website/form-layout.