Skip to content

Selectable cards whose corner check pops in, with a select-all that counts.

Selectionmotion@base-ui/react@number-flow/react

01Preview

Events to send

POST https://api.northwind.dev/hooks/ship

1 of 3 selected

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion @base-ui/react @number-flow/react

03Usage

import { CheckboxCard, CheckboxCardGroup } from "@/components/ui/checkbox-card";

<CheckboxCardGroup aria-label="Webhook events" allValues={["deploys", "issues"]} selectAll>
  <CheckboxCard value="deploys" title="Deployments" description="Created, succeeded or failed" icon={<Bolt />} />
  <CheckboxCard value="issues" title="Issues" description="Created, assigned and resolved" icon={<Alert />} />
</CheckboxCardGroup>

04Source

"use client";
import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox";
import { CheckboxGroup as BaseCheckboxGroup } from "@base-ui/react/checkbox-group";
import NumberFlow from "@number-flow/react";
import { motion, useReducedMotion } from "motion/react";
import { useId } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";
import { Checkbox } from "@/components/ui/checkbox";

export type CheckboxCardGroupProps = Omit<BaseCheckboxGroup.Props, "className" | "value" | "defaultValue" | "onValueChange"> & {
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
  /** Adds a select-all row above the cards. Needs `allValues`. */
  selectAll?: boolean;
  selectAllLabel?: React.ReactNode;
  /** Cards per row once the group is wider than 384px. Always one column below that. */
  columns?: 1 | 2 | 3;
  className?: string;
};

export function CheckboxCardGroup({
  value: valueProp,
  defaultValue = [],
  onValueChange,
  selectAll = false,
  selectAllLabel = "Select all",
  allValues,
  columns = 2,
  className,
  children,
  ...rest
}: CheckboxCardGroupProps) {
  // Held here so the select-all parent always has a controlled group to read.
  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });
  const total = allValues?.length ?? 0;
  const count = allValues ? value.filter((v) => allValues.includes(v)).length : value.length;

  return (
    <BaseCheckboxGroup
      value={value}
      onValueChange={(next) => setValue(next)}
      allValues={allValues}
      className={cn("@container flex w-full flex-col gap-2.5", className)}
      {...rest}
    >
      {selectAll && allValues && (
        <div className="flex min-h-7 items-center justify-between gap-3">
          <Checkbox parent size="sm" label={selectAllLabel} />
          {/* Fixed line box: the rolling digits sit inline-block and would nudge the baseline. */}
          <span className="flex h-5 shrink-0 items-center text-[12px] tabular text-fg-3">
            <span>
              {count === 0 ? (
                "None selected"
              ) : (
                <>
                  <NumberFlow value={count} className="text-fg-2" /> of {total} selected
                </>
              )}
            </span>
          </span>
        </div>
      )}
      <div
        className={cn(
          "grid gap-2",
          columns === 2 && "@sm:grid-cols-2",
          columns === 3 && "@sm:grid-cols-2 @xl:grid-cols-3",
        )}
      >
        {children}
      </div>
    </BaseCheckboxGroup>
  );
}

export type CheckboxCardProps = Omit<BaseCheckbox.Root.Props, "className" | "children" | "title" | "value" | "parent"> & {
  /** Identifies the card inside the group. */
  value: string;
  title: React.ReactNode;
  description?: React.ReactNode;
  /** A 14px icon, drawn in a tile at the start of the card. */
  icon?: React.ReactNode;
  /** Small trailing text beside the title, like a plan name or "Beta". */
  badge?: React.ReactNode;
  className?: string;
};

export function CheckboxCard({ value, title, description, icon, badge, className, ...rest }: CheckboxCardProps) {
  const titleId = useId();
  const descriptionId = useId();

  return (
    <BaseCheckbox.Root
      value={value}
      render={<div />}
      aria-labelledby={titleId}
      aria-describedby={description != null ? descriptionId : undefined}
      className={(state) =>
        cn(
          "group/card relative flex min-w-0 select-none items-start gap-3 rounded-xl border p-3 pr-9 text-left outline-none",
          "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
          "transition-[background-color,border-color,box-shadow,scale] duration-150 ease-out-expo",
          state.checked
            ? "border-fg-3 bg-hover shadow-[inset_0_0_0_0.5px_var(--fg-3)]"
            : "border-line-2 bg-raised",
          state.disabled
            ? "cursor-not-allowed opacity-50"
            : !state.readOnly && cn("motion-safe:active:scale-[0.985] active:duration-100", !state.checked && "hover:border-fg-4"),
          className,
        )
      }
      {...rest}
    >
      {icon != null && (
        <span
          aria-hidden
          className="grid size-8 shrink-0 place-items-center rounded-lg border border-line bg-frame text-fg-2 transition-colors duration-150 group-data-checked/card:text-fg [&_svg]:size-3.5"
        >
          {icon}
        </span>
      )}
      <span className="flex min-w-0 flex-col gap-0.5 pt-px">
        <span className="flex min-w-0 items-center gap-1.5">
          <span id={titleId} className="truncate text-[13px] font-medium leading-5 tracking-[-0.005em] text-fg">
            {title}
          </span>
          {badge != null && (
            <span className="shrink-0 rounded-full border border-line-2 px-1.5 text-[10.5px] leading-4 text-fg-2">{badge}</span>
          )}
        </span>
        {description != null && (
          <span id={descriptionId} className="line-clamp-2 text-[12.5px] leading-[18px] text-fg-3">
            {description}
          </span>
        )}
      </span>
      <BaseCheckbox.Indicator
        keepMounted
        className="pointer-events-none absolute right-3 top-3"
        render={(props, state) => (
          <span {...props}>
            <CornerCheck checked={state.checked} idle={!state.disabled && !state.readOnly} />
          </span>
        )}
      />
    </BaseCheckbox.Root>
  );
}

/** An empty ring that fills and pops, then draws its tick. */
function CornerCheck({ checked, idle }: { checked: boolean; idle: boolean }) {
  const reduce = useReducedMotion();
  return (
    <span className="relative grid size-4 place-items-center rounded-full">
      <span
        className={cn(
          "absolute inset-0 rounded-full border transition-[border-color,opacity] duration-150",
          checked ? "border-transparent" : cn("border-fg-4", idle && "group-hover/card:border-fg-3"),
        )}
      />
      <motion.span
        className="absolute inset-0 grid place-items-center rounded-full bg-fg text-frame"
        initial={false}
        animate={checked ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.5 }}
        // Reduced motion keeps the fade and snaps the scale. The targets stay the same so SSR matches.
        transition={reduce ? { opacity: { duration: 0.12 }, scale: { duration: 0 } } : checked ? spring.pop : { duration: 0.12, ease: ease.in }}
      >
        <svg width="10" height="10" viewBox="0 0 16 16" fill="none" aria-hidden focusable="false">
          <motion.path
            d="M3.5 8.5 L6.75 11.5 L12.5 4.75"
            stroke="currentColor"
            strokeWidth={2.4}
            strokeLinecap="round"
            strokeLinejoin="round"
            initial={false}
            animate={{ pathLength: checked ? 1 : 0, opacity: checked ? 1 : 0 }}
            transition={
              reduce
                ? { duration: 0 }
                : checked
                  ? { pathLength: { duration: 0.22, ease: ease.out, delay: 0.06 }, opacity: { duration: 0.01, delay: 0.06 } }
                  : { duration: 0.08 }
            }
          />
        </svg>
      </motion.span>
    </span>
  );
}

05Props

CheckboxCardGroup

PropTypeDefaultDescription
valuestring[]Controlled list of selected card values.
defaultValuestring[][]Uncontrolled initial selection.
onValueChange(value: string[]) => voidCalled with the new selection.
allValuesstring[]Every selectable value. Needed for selectAll and the count.
selectAllbooleanfalseAdds a select-all row that goes mixed, with a rolling “2 of 5 selected” count.
selectAllLabelReactNode"Select all"Label for the select-all checkbox.
columns1 | 2 | 32Cards per row once the group itself is wider than 384px; one column below that.
disabledbooleanfalseDisables every card.

CheckboxCard

PropTypeDefaultDescription
value*stringIdentifies the card in the group.
title*ReactNodeThe card's name and its accessible label. Truncates on one line.
descriptionReactNodeUp to two lines, then clamps. Linked as the description.
iconReactNodeIcon shown in a 32px tile at the start.
badgeReactNodeSmall pill beside the title, like “Beta”.
disabledbooleanfalseDims the card and removes it from the tab order.

06Notes

Behavior

  • The select-all only counts cards listed in allValues, so a disabled card left out of it never blocks “all”.
  • Columns follow the group's own width through a container query, so the same group works in a sidebar and a page.
  • Titles truncate and descriptions clamp to two lines; the corner check has reserved padding so text never runs under it.
  • The count reads “None selected” at zero rather than “0 of 5”.

Motion

  • The corner check fills and scales from 0.5 on the pop spring (600/30), then draws its tick in 220ms after a 60ms beat.
  • Deselecting fades and shrinks it in 120ms; the border steps from line-2 to fg-3 in 150ms.
  • The card presses to 0.985, less than a button, because it is large. The count rolls with NumberFlow. Reduced motion keeps a 120ms fade and drops scale, press and draw.

Accessibility

  • Each card is a role=checkbox labeled by its title and described by its description; Space toggles.
  • Every card is its own tab stop, as checkboxes are; the select-all is a real mixed-state checkbox, and the count beside it is read as plain text.
  • Label the group with aria-label or aria-labelledby pointing at the visible heading.