v1.0

Checkbox

Preview

Basic

Loading…

Preview

Code

ts
import Checkbox from '@/components/form/Checkbox';

src/components/form/Checkbox.tsx

AI prompt

text
Build a styled checkbox component (real input underneath, optional hint tooltip) in React + TypeScript + Tailwind CSS.

## Look
- Outer `<span class="flex items-start gap-1.5 text-xs">`; the whole thing drops to `opacity-50` when disabled.
- `<label htmlFor={id}>`: `flex items-start gap-2 min-w-0`, `cursor-pointer` (`cursor-not-allowed` when disabled).
- Box: a 16px slot (`relative w-4 h-4 shrink-0 mt-0.5`, centred) holding a real `<input type="checkbox" class="sr-only peer">` and a visual square `w-4 h-4 rounded border-2 transition-colors`:
  - unchecked `bg-white border-slate-300`, dark `bg-slate-800 border-slate-600`;
  - checked `bg-indigo-600 border-indigo-600` with a white lucide `Check` (12px, strokeWidth 3) absolutely centred on top;
  - keyboard focus `peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-400` on the square.
- Label text `font-medium text-slate-700 dark:text-slate-200`, `min-w-0` so long labels wrap beside the box, top-aligned.
- `hint` renders as a 12px ⓘ icon (slate-400, hover slate-600) with a dark tooltip, `mt-0.5` — NOT a second line under the label, which keeps a long list of permissions readable as a list. The ⓘ is a SIBLING of the `<label>`, not inside it: nested, its click would toggle the box.

## API
`id: string` (required, links label and input), `name?` (so it posts with an uncontrolled `<form>`), `checked: boolean`, `onChange(checked: boolean)`, `label: ReactNode`, `hint?: string`, `disabled = false`, `className?`. Controlled.

## Demo
Three stacked (`flex-col gap-3`): "Email me on approval" (checked, hint "One email per batch, not per row."), "Skip duplicates" (`name="skip_dupes"`), "Locked by policy" (disabled).

## 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 { Check } from 'lucide-react';
import { cn } from '@/lib/cn';
import { InfoTooltip } from '@/components/overlay/Tooltip';

/**
 * A checkbox that keeps a real <input> underneath, so labels and focus work.
 *
 * `hint` is unchanged as an API but no longer renders as a second line under the
 * label — it becomes an ⓘ tooltip beside it, which is what keeps a long list of
 * permissions or roles readable as a list. The ⓘ is a SIBLING of the <label>,
 * not a child: nested inside, its click would activate the label and toggle the
 * box on the way past.
 */
export default function Checkbox({
  id,
  name,
  checked,
  onChange,
  label,
  hint,
  disabled = false,
  className,
}: {
  id: string;
  /**
   * Form field name. Came across from marketing-stats' `StyledCheckbox`: without
   * it this cannot participate in an uncontrolled `<form>` post, which is how
   * several settings pages submit.
   */
  name?: string;
  checked: boolean;
  onChange: (checked: boolean) => void;
  label: React.ReactNode;
  hint?: string;
  disabled?: boolean;
  className?: string;
}) {
  return (
    <span
      className={cn(
        'flex items-start gap-1.5 text-xs',
        disabled && 'opacity-50',
        className,
      )}
    >
      <label
        htmlFor={id}
        className={cn(
          'flex items-start gap-2 min-w-0',
          disabled ? 'cursor-not-allowed' : 'cursor-pointer',
        )}
      >
        <span className="relative flex items-center justify-center w-4 h-4 shrink-0 mt-0.5">
          <input
            id={id}
            type="checkbox"
            checked={checked}
            disabled={disabled}
            onChange={(e) => onChange(e.target.checked)}
            name={name}
            className="sr-only peer"
          />
          <span
            className={cn(
              'w-4 h-4 rounded border-2 transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-400',
              checked
                ? 'bg-indigo-600 border-indigo-600'
                : 'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600',
            )}
          />
          {checked && <Check className="w-3 h-3 text-white absolute" strokeWidth={3} />}
        </span>
        <span className="min-w-0 font-medium text-slate-700 dark:text-slate-200">{label}</span>
      </label>

      {hint && <InfoTooltip content={hint} className="mt-0.5" iconClassName="w-3 h-3" />}
    </span>
  );
}

Props

PropTypeDefaultDescription
id*string—
checked*boolean—
onChange*(checked: boolean) => void—
label*React.ReactNode—
namestring—Form field name. Came across from marketing-stats' `StyledCheckbox`: without it this cannot participate in an uncontrolled `<form>` post, which is how several settings pages submit.
hintstring—
disabledbooleanfalse
classNamestring—