Expressive

Selection & input

Checkbox

Checkbox with a springy check mark, indeterminate and error states.

Checkboxes let people select one or more options from a list, or turn a single setting on or off when a switch would be too strong. Checking pops the box, softens its corners and draws the check in.

Installation

npx @grenishrai/expressive add checkbox

This copies checkbox.tsx into your components folder, along with what it needs from Expressive (utils, motion, styles), and installs @base-ui/react.

Usage

import { Checkbox } from "@/components/expressive/checkbox";

<label className="flex items-center gap-1">
  <Checkbox defaultChecked /> Email me about updates
</label>

<Checkbox checked={allSelected} indeterminate={someSelected} onCheckedChange={selectAll} />

Props

Checkbox

PropTypeDefaultDescription
checked / defaultCheckedboolean—Checked state (controlled / uncontrolled).
onCheckedChange(checked: boolean) => void—Called when toggled.
indeterminatebooleanfalseShows a dash for a partially selected group.
disabledboolean—Prevents interaction.
name / valuestring—Submitted with forms.
motion"expressive" | "standard" | "fluid" | "none"theme defaultMotion scheme for this component's transitions. none turns animation off.

Accessibility

  • Exposes role="checkbox" and aria-checked (including mixed when indeterminate).
  • Space toggles it. Wrap it in a <label> so the text is its accessible name and a larger click target.
  • Inside an invalid Base UI Field, it switches to the error color.

Source

This is the file the CLI writes into your project, shown with the default import paths. It's yours to change.

components/checkbox.tsx
"use client";

import * as React from "react";
import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox";
import { useComposedRef, useFluidPop } from "@/lib/expressive/use-fluid";
import { cn } from "@/lib/expressive/utils";
import type { MotionProp } from "@/lib/expressive/use-motion";

export interface CheckboxProps extends Omit<BaseCheckbox.Root.Props, "className"> {
  className?: string;
  motion?: MotionProp;
}

/*
 * Checkbox: a 20px squircle inside a 40px state-layer target. Checking pops
 * the box, softens its corners and draws the check mark in; pressing squishes
 * it. `indeterminate` draws a dash. Invalid fields turn it error red.
 */
export function Checkbox({ className, motion, ref, ...props }: CheckboxProps) {
  const root = React.useRef<HTMLElement>(null);
  const box = React.useRef<HTMLSpanElement>(null);
  useFluidPop(root, { attributes: ["data-checked", "data-indeterminate"], target: box, from: 0.6, motion });
  return (
    <BaseCheckbox.Root
      ref={useComposedRef(root, ref)}
      data-slot="checkbox"
      data-motion={motion}
      className={cn(
        "group state-layer focus-ring relative inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-shape-full text-on-surface-variant",
        "data-checked:text-primary data-indeterminate:text-primary data-invalid:text-error",
        "data-disabled:cursor-default data-disabled:opacity-(--ex-disabled-content) data-disabled:before:hidden",
        className,
      )}
      {...props}
    >
      <span
        ref={box}
        className={cn(
          // Border color is explicit: currentColor is the check mark's color.
          "flex size-5 items-center justify-center rounded-shape-extra-small corner-squircle border-2 border-on-surface-variant text-on-primary",
          "transition-[background-color,border-color,border-radius,transform] spring-spatial-fast group-active:scale-90",
          "group-data-invalid:border-error group-data-disabled:border-on-surface",
          "group-data-checked:animate-pop group-data-indeterminate:animate-pop",
          // Fluid: the jelly pop is script-driven, so drop the CSS pop and transform transition.
          "fluid:animate-none! fluid:transition-[background-color,border-color,border-radius] fluid:group-active:scale-100",
          "group-data-checked:rounded-shape-small group-data-indeterminate:rounded-shape-small",
          "group-data-checked:border-primary group-data-checked:bg-primary group-data-indeterminate:border-primary group-data-indeterminate:bg-primary",
          "group-data-invalid:group-data-checked:border-error group-data-invalid:group-data-checked:bg-error group-data-invalid:text-on-error",
          "group-data-disabled:group-data-checked:border-on-surface group-data-disabled:group-data-checked:bg-on-surface group-data-disabled:text-surface",
        )}
      >
        <BaseCheckbox.Indicator
          keepMounted
          className="flex"
          render={(indicatorProps, state) => (
            <span {...indicatorProps}>
              <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" className="size-4">
                {/* pathLength=1 lets the stroke "draw" via dashoffset 1 → 0. */}
                <path
                  d={state.indeterminate ? "M6 12h12" : "M5 12.5l4.5 4.5L19 7.5"}
                  pathLength={1}
                  strokeDasharray={1}
                  className="[stroke-dashoffset:1] transition-[stroke-dashoffset] spring-effects-default group-data-checked:[stroke-dashoffset:0] group-data-indeterminate:[stroke-dashoffset:0]"
                />
              </svg>
            </span>
          )}
        />
      </span>
    </BaseCheckbox.Root>
  );
}