Skip to content
UI Kit
Accent color

Checkbox

Real checkboxes in the kit look: label, description, mixed state, groups, cards and errors as text.

One of 9 components in Forms and inputs

2 variants

Variants

States and groups

The check draws itself and the box springs. "Select all" turns mixed when some rows are on, a group shares one legend and one hint.

Cards

The whole option is the target. Checked cards take a soft accent edge.

Details

What Checkbox brings along, read from the registry and the source.

Variants
2
Version
1.0.1 · since 1.0.0
File
components/ui/checkbox.tsx
Brings along
utilskiticoninput
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The CheckboxProps interface from components/ui/checkbox.tsx, with the comments from the code. Also takes every prop of Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'>.

  • labeloptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • indeterminateoptional
    boolean

    Neither checked nor unchecked, e.g. "select all" with some rows selected

  • erroroptional
    React.ReactNode

    Error as text under the label

  • variantoptional
    'default' | 'card'

    card: the whole option is a bordered surface that lights up when checked

  • iconoptional
    IconName
  • refoptional
    React.Ref<HTMLInputElement>

Accessibility

Read from the source of the component.

  • Sets aria-checked, aria-describedby and aria-invalid, so screen readers announce purpose and state.
  • Every field has a label, visible or for screen readers.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
  • 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, 161 lines
components/ui/checkbox.tsx
'use client';

import { useEffect, useId, useRef } from 'react';
import { useState } from 'react';
import { cn, prefersReducedMotion, useInView } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { FieldError, FieldHint } from '@/components/ui/input';

export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {
  label?: React.ReactNode;
  description?: React.ReactNode;
  /** Neither checked nor unchecked, e.g. "select all" with some rows selected */
  indeterminate?: boolean;
  /** Error as text under the label */
  error?: React.ReactNode;
  /** card: the whole option is a bordered surface that lights up when checked */
  variant?: 'default' | 'card';
  icon?: IconName;
  ref?: React.Ref<HTMLInputElement>;
  className?: string;
}

/** A spring on the box: it dips, overshoots a little, settles */
function spring(el: HTMLElement) {
  if (prefersReducedMotion() || typeof el.animate !== 'function') return;
  el.animate([{ transform: 'scale(1)' }, { transform: 'scale(0.84)', offset: 0.25 }, { transform: 'scale(1.08)', offset: 0.65 }, { transform: 'scale(1)' }], { duration: 420, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
}

/** The box itself. A real checkbox input, drawn by the kit. The check draws itself, the box springs. */
export function CheckboxBox({ indeterminate, className, ref, onChange, ...rest }: Omit<CheckboxProps, 'label' | 'description' | 'error' | 'variant' | 'icon'>) {
  const own = useRef<HTMLInputElement>(null);
  useEffect(() => {
    if (own.current) own.current.indeterminate = Boolean(indeterminate);
  }, [indeterminate]);
  return (
    <span className={cn('relative grid size-[18px] shrink-0 place-items-center', className)}>
      <input
        {...rest}
        ref={(el) => {
          own.current = el;
          if (typeof ref === 'function') ref(el);
          else if (ref) (ref as React.RefObject<HTMLInputElement | null>).current = el;
        }}
        type="checkbox"
        aria-checked={indeterminate ? 'mixed' : undefined}
        onChange={(e) => {
          spring(e.currentTarget);
          onChange?.(e);
        }}
        className={cn(
          'peer absolute inset-0 m-0 size-full cursor-pointer appearance-none rounded-[5px] border border-border-strong bg-background shadow-[var(--shadow-sm)] outline-none transition-[background-color,border-color,box-shadow] duration-150',
          'hover:border-[color-mix(in_oklab,var(--foreground)_35%,var(--border-strong))]',
          'checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary',
          'focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_22%,transparent)] focus-visible:outline-none',
          'aria-invalid:border-destructive aria-invalid:checked:bg-destructive',
          'disabled:cursor-not-allowed disabled:opacity-50',
        )}
      />
      <svg
        viewBox="0 0 24 24"
        width={14}
        height={14}
        fill="none"
        aria-hidden
        className="pointer-events-none relative text-primary-foreground peer-indeterminate:opacity-0 [&_path]:[stroke-dashoffset:1] [&_path]:transition-[stroke-dashoffset] [&_path]:duration-150 peer-checked:[&_path]:[stroke-dashoffset:0] peer-checked:[&_path]:duration-300 peer-checked:[&_path]:delay-75 motion-reduce:[&_path]:transition-none"
      >
        <path d="m5 12.5 4.5 4.5L19 7.5" pathLength={1} strokeDasharray="1 1" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
      </svg>
      <span aria-hidden className="pointer-events-none absolute h-[2px] w-2 scale-x-0 rounded-full bg-primary-foreground opacity-0 transition-[opacity,scale] duration-200 ease-[var(--ease-kit)] peer-indeterminate:scale-x-100 peer-indeterminate:opacity-100" />
    </span>
  );
}

/**
 * Checkbox with label, description and error. card turns the option into a selectable surface.
 *   <Checkbox label="Send me the monthly report" description="First Monday of the month" />
 */
export function Checkbox({ label, description, error, variant = 'default', icon, className, id, ...rest }: CheckboxProps) {
  const auto = useId();
  const cid = id ?? auto;
  const descId = description ? `${cid}-desc` : undefined;
  const errId = error ? `${cid}-err` : undefined;
  const box = <CheckboxBox {...rest} id={cid} aria-describedby={cn(descId, errId) || undefined} aria-invalid={error ? true : rest['aria-invalid']} />;
  if (!label) return box;
  const card = variant === 'card';
  return (
    <div className={cn('min-w-0', card && 'flex flex-col', className)}>
      <label
        htmlFor={cid}
        className={cn(
          'group/check flex cursor-pointer items-start gap-3',
          // Phones: the whole row is a 44 px tap target, 12 px above and below the first line belong to it
          !card && 'max-sm:py-3',
          card &&
            'relative flex-1 rounded-xl border border-border bg-card p-4 shadow-[var(--shadow-sm)] transition-[border-color,background-color,box-shadow,translate] duration-200 hover:-translate-y-px hover:border-border-strong motion-reduce:hover:translate-y-0 has-[:checked]:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] has-[:checked]:bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))] has-[:checked]:shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_55%,var(--border)),var(--shadow-sm)] has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_22%,transparent)]',
          rest.disabled && 'cursor-not-allowed opacity-60',
        )}
      >
        {card && icon && (
          <span className="grid size-9 shrink-0 place-items-center rounded-lg border border-border bg-surface text-muted-foreground transition-[color,border-color,scale] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] group-has-[:checked]/check:scale-105 group-has-[:checked]/check:border-[color-mix(in_oklab,var(--primary)_35%,var(--border))] group-has-[:checked]/check:text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
            <Icon name={icon} size={18} />
          </span>
        )}
        <span className={cn('min-w-0 flex-1', card ? 'order-none' : 'order-2')}>
          <span className="block text-sm font-medium leading-5 text-foreground">{label}</span>
          {description && (
            <span id={descId} className="mt-0.5 block text-[13px] leading-5 text-muted-foreground">
              {description}
            </span>
          )}
        </span>
        <span className={cn('flex h-5 items-center', card ? 'order-3' : 'order-1')}>{box}</span>
      </label>
      {error && (
        <FieldError id={errId} className="mt-2">
          {error}
        </FieldError>
      )}
    </div>
  );
}

export interface CheckboxGroupProps {
  legend: React.ReactNode;
  description?: React.ReactNode;
  error?: React.ReactNode;
  /** Lay the options out in columns from tablet up */
  columns?: 1 | 2 | 3;
  className?: string;
  children: React.ReactNode;
}

/** A set of checkboxes with a legend, a hint and one error for the whole set. The options rise one after another as the set scrolls in. */
export function CheckboxGroup({ legend, description, error, columns = 1, className, children }: CheckboxGroupProps) {
  const id = useId();
  const [ref, inView] = useInView<HTMLDivElement>();
  const [done, setDone] = useState(false);
  useEffect(() => {
    if (!inView) return;
    const t = window.setTimeout(() => setDone(true), 1300);
    return () => window.clearTimeout(t);
  }, [inView]);
  return (
    <fieldset className={cn('min-w-0', className)} aria-describedby={error ? `${id}-err` : description ? `${id}-desc` : undefined} aria-invalid={error ? true : undefined}>
      <legend className="text-sm font-medium text-foreground">{legend}</legend>
      {description && <FieldHint id={`${id}-desc`} className="mt-1">{description}</FieldHint>}
      <div
        ref={ref}
        style={{ '--kit-step': '70ms' } as React.CSSProperties}
        className={cn('kit-stagger grid gap-x-4 gap-y-0 sm:mt-4 sm:gap-4', !inView && 'kit-stagger-wait', done && 'kit-stagger-done', columns === 2 && 'sm:grid-cols-2', columns === 3 && 'sm:grid-cols-2 lg:grid-cols-3')}
      >
        {children}
      </div>
      {error && (
        <FieldError id={`${id}-err`} className="mt-3">
          {error}
        </FieldError>
      )}
    </fieldset>
  );
}

Frequent questions

How do I install Checkbox?

With aiw add @ai-website/checkbox. The command puts components/ui/checkbox.tsx into your project, the code is yours from then on. It brings along icon and input. It needs no further npm packages.

Which variants does Checkbox have?

2 variants, States and groups and Cards.

Can an AI assistant build with Checkbox?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Checkbox with aiw_components and install it with aiw_add @ai-website/checkbox. Name the variant in your prompt, for example variant="card", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard