Healthcare

Consent Checklist

A required-consent checklist that draws each check and gates a primary action until every required item is acknowledged, with a live remaining count.

Install

npx shadcn@latest add @paragon/consent-checklist

Also installs: button

consent-checklist.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Button } from "@/registry/paragon/ui/button";
import { cn } from "@/lib/utils";

export interface ConsentItem {
  id: string;
  label: string;
  /** Sub-text under the label. */
  description?: string;
  /** Required consents gate the primary action. Defaults to true. */
  required?: boolean;
}

export interface ConsentChecklistProps
  extends Omit<React.ComponentProps<"div">, "onSubmit"> {
  items: ConsentItem[];
  /** Primary action label. */
  actionLabel?: string;
  /** Fires with the set of checked ids when the gated action is taken. */
  onConsent?: (checkedIds: string[]) => void;
  static?: boolean;
}

/**
 * A consent checklist that gates a primary action until every required item
 * is acknowledged. Toggling an item draws its check (pathLength +
 * stroke-dashoffset transition, retargetable). A live count shows remaining
 * required consents; when the last one lands, the action enables and its label
 * blur-swaps in. Reduced motion keeps the color settle and drops the draw.
 */
export function ConsentChecklist({
  items,
  actionLabel = "Continue",
  onConsent,
  static: isStatic = false,
  className,
  ...props
}: ConsentChecklistProps) {
  const [checked, setChecked] = React.useState<Set<string>>(() => new Set());
  const reducedMotion = useReducedMotion();
  const animate = !isStatic && !reducedMotion;

  const requiredIds = items.filter((i) => i.required !== false).map((i) => i.id);
  const remaining = requiredIds.filter((id) => !checked.has(id)).length;
  const ready = remaining === 0;

  const toggle = (id: string) =>
    setChecked((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });

  return (
    <div
      data-slot="consent-checklist"
      className={cn(
        "flex w-full max-w-md flex-col gap-1 rounded-xl bg-card p-3 shadow-border",
        className,
      )}
      {...props}
    >
      <ul className="flex flex-col gap-0.5">
        {items.map((item) => {
          const isChecked = checked.has(item.id);
          const isRequired = item.required !== false;
          return (
            <li key={item.id} className="flex">
              <button
                type="button"
                role="checkbox"
                aria-checked={isChecked}
                onClick={() => toggle(item.id)}
                className="group flex w-full items-start gap-3 rounded-lg px-2 py-2.5 text-left transition-colors duration-(--duration-fast) hover:bg-accent/60"
              >
                <span
                  aria-hidden
                  className={cn(
                    "mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-[6px] border",
                    isChecked
                      ? "border-primary bg-primary text-primary-foreground"
                      : "border-input bg-transparent text-transparent",
                    !isStatic &&
                      "transition-[background-color,border-color] duration-(--duration-quick) ease-out",
                  )}
                >
                  <svg viewBox="0 0 14 14" fill="none" className="size-3.5">
                    <path
                      d="M3 7.4 5.6 10 11 4"
                      pathLength={1}
                      stroke="currentColor"
                      strokeWidth={1.9}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeDasharray={1}
                      style={{
                        strokeDashoffset: isChecked ? 0 : 1,
                        transition: isStatic
                          ? undefined
                          : isChecked
                            ? "stroke-dashoffset 200ms var(--ease-out) 60ms"
                            : "stroke-dashoffset 100ms var(--ease-exit)",
                      }}
                    />
                  </svg>
                </span>
                <span className="flex min-w-0 flex-1 flex-col gap-0.5">
                  <span className="flex items-center gap-1.5 text-sm text-foreground">
                    {item.label}
                    {!isRequired && (
                      <span className="text-[11px] font-normal text-muted-foreground">
                        (optional)
                      </span>
                    )}
                  </span>
                  {item.description && (
                    <span className="text-xs text-muted-foreground">
                      {item.description}
                    </span>
                  )}
                </span>
              </button>
            </li>
          );
        })}
      </ul>

      <div className="mt-1 flex items-center justify-between gap-3 px-2 pt-1">
        <span
          aria-live="polite"
          className="text-xs text-muted-foreground tabular-nums"
        >
          {ready
            ? "All required consents acknowledged"
            : `${remaining} required consent${remaining === 1 ? "" : "s"} remaining`}
        </span>
        <Button
          size="sm"
          disabled={!ready}
          onClick={() => onConsent?.([...checked])}
        >
          {animate ? (
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span
                key={ready ? "ready" : "waiting"}
                initial={{ opacity: 0, y: 6, filter: "blur(4px)" }}
                animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                exit={{ opacity: 0, y: -6, filter: "blur(4px)" }}
                transition={{ type: "spring", duration: 0.3, bounce: 0 }}
              >
                {actionLabel}
              </motion.span>
            </AnimatePresence>
          ) : (
            actionLabel
          )}
        </Button>
      </div>
    </div>
  );
}