Skip to content
UI Kit
Accent color

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

3 variants

Variants

Contact

Two columns on wide screens, one on phones. The fields rise one after another as the form scrolls in, sending turns the button into a check.

Application

Sections on top of each other, an error summary that links to each field, upload and tags. Sending with open errors shakes the button and names what is left.

Settings

Title and explanation on the left from desktop up, fields on the right, actions at the end.

Details

What Form layouts brings along, read from the registry and the source.

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

Props

The FormSectionProps interface from components/ui/form-layout.tsx, with the comments from the code.

  • titlerequired
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • layoutoptional
    'side' | 'top'

    side: title in a column on the left from desktop up. top: title above the fields.

  • childrenrequired
    React.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
components/ui/form-layout.tsx
'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.

Often searched

Jump to

Copied to the clipboard