Checkbox

A control that allows the user to toggle between checked and not checked.

By clicking this checkbox, you agree to the terms.

"use client"

import * as React from "react"
import { cn } from "cn"
import {
  Checkbox as CheckboxPrimitive,
  composeRenderProps,
  type CheckboxProps,
} from "@animate-ui/react/primitives/react-aria"
import { AnimateUIProvider } from "@animate-ui/react/provider"

import { Tick02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldTitle,
} from "@/components/ui/field"
import { Label } from "@/components/ui/label"

function Checkbox({ className, children, ...props }: CheckboxProps) {
  return (
    <CheckboxPrimitive
      data-slot="checkbox"
      className={cn(
        "border-input dark:bg-input/30 data-checked:text-primary-foreground aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors duration-300 group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input group-has-[:focus-visible]/field-label:data-checked:border-primary data-selected:text-primary-foreground data-focus-visible:border-ring data-focus-visible:ring-ring/50 data-focus-visible:ring-3 data-invalid:border-destructive dark:data-invalid:border-destructive/50 data-invalid:ring-destructive/20 dark:data-invalid:ring-destructive/40 data-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 before:absolute before:-inset-px before:rounded-[inherit] before:bg-primary before:opacity-0 before:scale-70 before:transition-[scale,opacity] before:duration-300 motion-reduce:before:scale-100 data-selected:before:opacity-100 data-selected:before:scale-100 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
        className
      )}
      {...props}
    >
      {composeRenderProps(
        children,
        (children, { isSelected, isIndeterminate }) => (
          <>
            <span
              data-slot="checkbox-indicator"
              className="[&>svg]:size-3.5 relative grid place-content-center text-current transition-none"
            >
              {(isSelected || isIndeterminate) && (
                <HugeiconsIcon icon={Tick02Icon} strokeWidth={2}
                />
              )}
            </span>
            {children}
          </>
        )
      )}
    </CheckboxPrimitive>
  )
}

export { Checkbox }

export default function CheckboxDemo({
  checked: checkedProp = true,
  checkAnimation = "draw",
}: {
  checked?: boolean | "indeterminate"
  checkAnimation?: "draw" | "pop"
}) {
  const [checked, setChecked] = React.useState(checkedProp)
  const [prev, setPrev] = React.useState(checkedProp)
  if (checkedProp !== prev) {
    setPrev(checkedProp)
    setChecked(checkedProp)
  }

  return (
    <AnimateUIProvider checkAnimation={checkAnimation}>
      <FieldGroup className="max-w-sm">
        <Field orientation="horizontal">
          <Checkbox id="terms-checkbox" name="terms-checkbox" />
          <Label htmlFor="terms-checkbox">Accept terms and conditions</Label>
        </Field>
        <Field orientation="horizontal">
          <Checkbox
            id="terms-checkbox-2"
            name="terms-checkbox-2"
            isSelected={checked === true}
            isIndeterminate={checked === "indeterminate"}
            onChange={setChecked}
          />
          <FieldContent>
            <FieldLabel htmlFor="terms-checkbox-2">
              Accept terms and conditions
            </FieldLabel>
            <FieldDescription>
              By clicking this checkbox, you agree to the terms.
            </FieldDescription>
          </FieldContent>
        </Field>
        <Field orientation="horizontal" data-disabled>
          <Checkbox id="toggle-checkbox" name="toggle-checkbox" isDisabled />
          <FieldLabel htmlFor="toggle-checkbox">Enable notifications</FieldLabel>
        </Field>
        <FieldLabel>
          <Field orientation="horizontal">
            <Checkbox id="toggle-checkbox-2" name="toggle-checkbox-2" />
            <FieldContent>
              <FieldTitle>Enable notifications</FieldTitle>
              <FieldDescription>
                You can enable or disable notifications at any time.
              </FieldDescription>
            </FieldContent>
          </Field>
        </FieldLabel>
      </FieldGroup>
    </AnimateUIProvider>
  )
}

Installation

Install the packages

npm install @animate-ui/react motion
pnpm add @animate-ui/react motion
yarn add @animate-ui/react motion
bun add @animate-ui/react motion

Add the shadcn/ui component

npx shadcn@latest add checkbox
pnpm dlx shadcn@latest add checkbox
yarn dlx shadcn@latest add checkbox
bun x shadcn@latest add checkbox

Animate it

The CLI finds your shadcn/ui components through components.json, swaps their primitive imports for Animate UI's and removes the CSS animation classes Motion replaces.

npx @animate-ui/cli@latest animate checkbox
pnpm dlx @animate-ui/cli@latest animate checkbox
yarn dlx @animate-ui/cli@latest animate checkbox
bun x @animate-ui/cli@latest animate checkbox

Usage

Import from Animate UI instead of react-aria-components: it re-exports all of React Aria, with the animated controls below. Everything else stays the same.

import { Checkbox } from '@animate-ui/react/primitives/react-aria';

export function Terms() {
  return (
    <Checkbox defaultSelected>
      {({ isSelected }) => (
        <>
          <span data-slot="checkbox-indicator">
            {isSelected && <CheckIcon />}
          </span>
          Accept terms and conditions
        </>
      )}
    </Checkbox>
  );
}

React Aria lets you render the indicator yourself. Animate UI animates the content of the element marked data-slot="checkbox-indicator", which is what shadcn/ui renders: add the attribute to your own indicator.

Check animation

The check draws itself by default. With animation="pop" on the Checkbox, it pops in from half its size instead, and shrinks back out:

<Checkbox defaultSelected animation="pop">
  {({ isSelected }) => (
    <>
      <span data-slot="checkbox-indicator">{isSelected && <CheckIcon />}</span>
      Accept terms and conditions
    </>
  )}
</Checkbox>

The same demo with checkAnimation="pop" (its Controls switch it back to draw):

By clicking this checkbox, you agree to the terms.

To pop every check of your app at once (checkboxes, menus, selects, comboboxes), set checkAnimation="pop" on an AnimateUIProvider instead.

Animation

The motion of Animate UI v1's Radix UI checkbox (v1 had no React Aria one):

  • Indicator: the check you render inside data-slot="checkbox-indicator" draws itself when checking the box mounts it. Its stroke (pathLength) and its opacity go from 0 to 1 together in 0.3s, after a 0.2s delay, on Motion's default ease-out (v1's delay and curve, drawn a little slower than v1's 0.2s). When it unmounts, a copy of it erases in 0.3s in its place, then goes away: your render function stays the single source of truth, React Aria's state and render props are never delayed. Content that isn't an SVG shape fades the same way.
  • With animation="pop" (or checkAnimation="pop" on an AnimateUIProvider), the check pops instead: after the same 0.2s, it springs in from half its size out of a 2px blur (stiffness 500, damping 24, a little past its size), and shrinks back into its blur in 0.15s when the box is unchecked. Its stroke stays whole. The demo's Controls switch between the two.
  • Checkbox doesn't move: unlike v1's checkbox, it doesn't scale when pressed or hovered. The check (and, in the demo, the box's fill) answers the click.
  • In the demo, the box's fill is a layer of its own (before: in its classes) that grows from 70% while it fades in, in 300ms, and shrinks back as it fades out: the check draws over a box that is nearly full. The layer covers the box's border (before:-inset-px) and the border keeps its color, so no outline rings the growing fill. It's CSS, so copy the classes for the same effect; under reduced motion the layer only fades.
  • Checking with Space animates the same way: the state changes at once, the animation never delays it.
  • A box that is already checked when it appears (on load, in a dialog that opens) renders at rest: only checking and unchecking animate it. On load, the check is your markup, rendered by the server before any script runs.
  • Toggling again mid-animation turns back from where the check is.
  • isIndeterminate shows the indicator like isSelected; an icon swapped for the indeterminate state draws without the delay, as v1's indeterminate line did. Inside a CheckboxGroup, each checkbox animates the same way.
  • Under reduced motion, the check only fades, drawn or popped (its stroke stays whole, nothing scales).
  • The box's own colours are the component's CSS: shadcn/ui's transition-colors fades them.
  • className and style render props keep working.

API Reference

Checkbox takes exactly the props of React Aria's Checkbox, plus the props below.

PropTypeDefault
animation?
'draw' | 'pop'the closest AnimateUIProvider's checkAnimation, else 'draw'
transition?
Transitiondraw: { duration: 0.3, delay: 0.2, ease: [0, 0, 0.58, 1] }; pop: { type: 'spring', stiffness: 500, damping: 24, delay: 0.2 }
exitTransition?
Transitiondraw: { duration: 0.3, ease: [0, 0, 0.58, 1] }; pop: { duration: 0.15, ease: [0.23, 1, 0.32, 1] }

AnimateUIProvider's tempo scales these transitions with everything else.

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/18/2026

On this page