v1.0

Fieldset

Preview

Basic

Loading…

Preview

Code

ts
import Fieldset from '@/components/layout/Fieldset';

src/components/layout/Fieldset.tsx

AI prompt

text
Build a fieldset (bordered control group with a legend, optionally collapsible) component in React + TypeScript + Tailwind CSS.

A real `<fieldset>` / `<legend>` rather than a div with a label: the legend becomes the group's accessible name, so each control inside is announced with the question it answers ("Notifications, Email, checkbox"), and `disabled` switches off every control in one native attribute.

## Look
- `<fieldset>`: `min-w-0 rounded-lg border border-slate-200 dark:border-slate-700 px-4`, `pb-4` — shrinking to `pb-1` when collapsed, so a collapsed fieldset is a tidy single rule with the legend on it, not an empty box.
- `<legend>`: sits on the top border, `px-1.5 text-xs font-semibold text-slate-700 dark:text-slate-200`.
- Body: `pt-2 text-xs text-slate-600 dark:text-slate-300`.
- Toggleable: the legend holds a button (`-mx-1 flex items-center gap-1 rounded-md px-1 py-0.5`, `hover:text-indigo-600 dark:hover:text-indigo-400`, focus ring indigo-400) with a 14px `ChevronDown` (slate-400 / dark slate-500) before the text, rotated `-rotate-90` when collapsed.

## Behaviour
- The button goes INSIDE the legend rather than replacing it: the legend still names the group, the button takes focus.
- Collapse animates height with a `grid-template-rows` 1fr ↔ 0fr transition (`duration-200 ease-out`, none under reduced motion) — no measuring. The inner wrapper is `min-h-0` so the 0fr row can reach zero.
- The body clips (`overflow-hidden`) only while collapsed and for 250ms while expanding (a timer, not `transitionend`, which never fires under reduced motion). Once open it stops clipping, so a dropdown inside is never cut off.
- The collapsed body is `inert`, so Tab cannot land on a hidden control.
- Only a toggleable fieldset can be collapsed — a stray `collapsed` on a static one is ignored rather than hiding content with no way back.
- Controlled when `collapsed` is passed, otherwise internal from `defaultCollapsed`. `onToggle(next)` gets the NEXT collapsed state.

## API
`legend: ReactNode`, `toggleable?` (false), `collapsed?: boolean`, `defaultCollapsed?` (false), `onToggle?: (collapsed: boolean) => void`, `disabled?` (false), `className`, `children`.

## Accessibility
The toggle button has `aria-expanded` and `aria-controls` pointing at the body (id from `useId`).

## Demo
A two-column grid: "Notifications" (Email ✓, Push, Weekly digest ✓ checkboxes); a controlled toggleable "Advanced" with a "Retry limit" input; a toggleable one that starts collapsed; and a disabled group with two locked checkboxes.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { useEffect, useId, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/cn';

export interface FieldsetProps {
  /** The group's caption. Rendered in a real `<legend>`, so it names the group for a screen reader. */
  legend: React.ReactNode;
  /** Turns the legend into a button that collapses and expands the content. */
  toggleable?: boolean;
  /** Controlled collapsed state. Leave undefined to let the fieldset own it. */
  collapsed?: boolean;
  /** Initial state when uncontrolled. */
  defaultCollapsed?: boolean;
  /** Fires with the NEXT collapsed state. */
  onToggle?: (collapsed: boolean) => void;
  /** Disables every control inside — the native `<fieldset disabled>` behaviour, not a restyle. */
  disabled?: boolean;
  className?: string;
  children?: React.ReactNode;
}

/**
 * A bordered group of related controls under a caption.
 *
 * A real `<fieldset>`/`<legend>` rather than a div with a label on it: the
 * legend becomes the group's accessible name, so each radio or input inside is
 * announced with the question it answers ("Notifications, Email, checkbox"),
 * and `disabled` switches off every control in one attribute.
 *
 * When `toggleable`, the button goes INSIDE the legend rather than replacing
 * it — the legend still names the group, and the button is what takes focus.
 * The collapse animates height through a `grid-template-rows` 1fr→0fr
 * transition, which needs no measuring; the collapsed body is also `inert`, so
 * Tab cannot land on a control nobody can see.
 */
export default function Fieldset({
  legend,
  toggleable = false,
  collapsed,
  defaultCollapsed = false,
  onToggle,
  disabled = false,
  className,
  children,
}: FieldsetProps) {
  const bodyId = useId();
  const [internal, setInternal] = useState(defaultCollapsed);
  // Only a toggleable fieldset can be collapsed; a stray `collapsed` on a
  // static one would otherwise hide content with no way to bring it back.
  const isCollapsed = toggleable && (collapsed ?? internal);

  // The body clips while collapsed AND for the length of the expand
  // animation — without the second, the content spills over whatever follows
  // while the row is still growing. It stops clipping once open, so a dropdown
  // inside an open fieldset is never cut off. A timer rather than
  // `transitionend`, because under reduced motion no transition runs and that
  // event never fires.
  const [opening, setOpening] = useState(false);
  const [prevCollapsed, setPrevCollapsed] = useState(isCollapsed);
  if (prevCollapsed !== isCollapsed) {
    setPrevCollapsed(isCollapsed);
    setOpening(!isCollapsed);
  }
  useEffect(() => {
    if (!opening) return;
    const t = setTimeout(() => setOpening(false), 250);
    return () => clearTimeout(t);
  }, [opening]);

  const toggle = () => {
    const next = !isCollapsed;
    if (collapsed === undefined) setInternal(next);
    onToggle?.(next);
  };

  return (
    <fieldset
      disabled={disabled}
      className={cn(
        'min-w-0 rounded-lg border border-slate-200 px-4 dark:border-slate-700',
        // The legend sits ON the top border; the bottom padding shrinks with
        // the body so a collapsed fieldset is a tidy single rule, not a box.
        isCollapsed ? 'pb-1' : 'pb-4',
        className,
      )}
    >
      <legend className="px-1.5 text-xs font-semibold text-slate-700 dark:text-slate-200">
        {toggleable ? (
          <button
            type="button"
            onClick={toggle}
            aria-expanded={!isCollapsed}
            aria-controls={bodyId}
            className={cn(
              '-mx-1 flex items-center gap-1 rounded-md px-1 py-0.5 transition-colors',
              'hover:text-indigo-600 dark:hover:text-indigo-400',
              'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400',
            )}
          >
            <ChevronDown
              className={cn('h-3.5 w-3.5 text-slate-400 transition-transform dark:text-slate-500', isCollapsed && '-rotate-90')}
              aria-hidden
            />
            {legend}
          </button>
        ) : (
          legend
        )}
      </legend>

      <div
        id={bodyId}
        inert={isCollapsed}
        className={cn(
          'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
          isCollapsed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]',
        )}
      >
        {/* `min-h-0` is what lets the 0fr row actually reach zero. */}
        <div className={cn('min-h-0', (isCollapsed || opening) && 'overflow-hidden')}>
          <div className="pt-2 text-xs text-slate-600 dark:text-slate-300">{children}</div>
        </div>
      </div>
    </fieldset>
  );
}

Props

PropTypeDefaultDescription
legend*React.ReactNode—The group's caption. Rendered in a real `<legend>`, so it names the group for a screen reader.
toggleablebooleanfalseTurns the legend into a button that collapses and expands the content.
collapsedboolean—Controlled collapsed state. Leave undefined to let the fieldset own it.
defaultCollapsedbooleanfalseInitial state when uncontrolled.
onToggle(collapsed: boolean) => void—Fires with the NEXT collapsed state.
disabledbooleanfalseDisables every control inside — the native `<fieldset disabled>` behaviour, not a restyle.
classNamestring—
childrenReact.ReactNode—