Security

Compliance Checklist

A SOC 2 / ISO control checklist with per-control evidence status, met/in-progress/gap chips, and an overall completion bar that fills on view.

Install

npx shadcn@latest add @paragon/compliance-checklist

compliance-checklist.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { Check, Clock, FileText, X } from "lucide-react";
import { cn } from "@/lib/utils";

export type ControlStatus = "met" | "in-progress" | "gap";

export interface ComplianceControl {
  id: string;
  label: string;
  status: ControlStatus;
  /** Evidence artifact name, e.g. "AWS Config export". */
  evidence?: string;
}

export interface ComplianceChecklistProps
  extends React.ComponentProps<"div"> {
  /** Framework name shown in the header. */
  framework?: string;
  controls?: ComplianceControl[];
  /** Disables the progress-bar fill-in. */
  static?: boolean;
}

const STATUS_META: Record<
  ControlStatus,
  { icon: React.ElementType; ring: string; bg: string; label: string }
> = {
  met: { icon: Check, ring: "text-success", bg: "bg-success/12", label: "Met" },
  "in-progress": {
    icon: Clock,
    ring: "text-warning",
    bg: "bg-warning/12",
    label: "In progress",
  },
  gap: { icon: X, ring: "text-destructive", bg: "bg-destructive/12", label: "Gap" },
};

const DEFAULT_CONTROLS: ComplianceControl[] = [
  { id: "CC6.1", label: "Logical access controls enforced", status: "met", evidence: "Okta policy export" },
  { id: "CC6.6", label: "Encryption in transit (TLS 1.2+)", status: "met", evidence: "SSL Labs A+ scan" },
  { id: "CC7.2", label: "Security monitoring & alerting", status: "in-progress", evidence: "Datadog dashboard" },
  { id: "CC7.3", label: "Incident response plan tested", status: "in-progress" },
  { id: "CC8.1", label: "Change management approvals", status: "met", evidence: "GitHub PR reviews" },
  { id: "CC9.2", label: "Vendor risk assessments current", status: "gap" },
];

/**
 * A SOC 2 / ISO control checklist with per-control evidence status and an
 * overall completion bar. The bar fills via a CSS width-free transform on a
 * scaled track (scaleX with fixed origin) when the card enters view — no layout
 * animation. Each row shows a met / in-progress / gap chip. Reduced motion
 * renders the final fill immediately.
 */
export function ComplianceChecklist({
  framework = "SOC 2 Type II",
  controls = DEFAULT_CONTROLS,
  static: isStatic = false,
  className,
  ...props
}: ComplianceChecklistProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
  const animate = !isStatic && !reduced;
  const armed = !animate || inView;

  const met = controls.filter((c) => c.status === "met").length;
  const total = controls.length || 1;
  const pct = Math.round((met / total) * 100);
  const shown = armed ? pct : 0;

  return (
    <div
      ref={ref}
      data-slot="compliance-checklist"
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-5 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-center justify-between">
        <div>
          <h3 className="text-sm font-semibold">{framework}</h3>
          <p className="mt-0.5 text-xs text-muted-foreground">
            <span className="tabular-nums">{met}</span> of{" "}
            <span className="tabular-nums">{controls.length}</span> controls met
          </p>
        </div>
        <span className="text-2xl font-semibold tabular-nums">{shown}%</span>
      </div>

      {/* Progress bar — scaleX fill, no layout animation */}
      <div className="mt-3 h-2 overflow-hidden rounded-full bg-secondary">
        <div
          className="h-full origin-left rounded-full bg-success"
          style={{
            transform: `scaleX(${shown / 100})`,
            transition: animate
              ? "transform 700ms var(--ease-out)"
              : undefined,
          }}
        />
      </div>

      <ul className="mt-4 divide-y">
        {controls.map((control) => {
          const meta = STATUS_META[control.status];
          const Icon = meta.icon;
          return (
            <li key={control.id} className="flex items-center gap-3 py-2.5">
              <span
                className={cn(
                  "flex size-6 shrink-0 items-center justify-center rounded-full",
                  meta.bg,
                  meta.ring,
                )}
              >
                <Icon className="size-3.5" />
              </span>
              <div className="min-w-0 flex-1">
                <p className="truncate text-[13px] font-medium">
                  <span className="text-muted-foreground tabular-nums">
                    {control.id}
                  </span>{" "}
                  {control.label}
                </p>
                {control.evidence && (
                  <p className="mt-0.5 flex items-center gap-1 text-[11px] text-muted-foreground">
                    <FileText className="size-3" />
                    {control.evidence}
                  </p>
                )}
              </div>
              <span className={cn("shrink-0 text-[11px] font-medium", meta.ring)}>
                {meta.label}
              </span>
            </li>
          );
        })}
      </ul>
    </div>
  );
}