Cards

Flashcard

A study card that flips in 3D between prompt and answer, with spaced-repetition grade buttons on the back.

Install

npx shadcn@latest add @paragon/flashcard

flashcard.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type FlashcardGrade = "again" | "hard" | "good" | "easy";

export interface FlashcardProps
  extends Omit<React.ComponentProps<"div">, "onError"> {
  prompt: React.ReactNode;
  answer: React.ReactNode;
  /** Small label on the prompt face, e.g. a deck or hint. */
  promptLabel?: string;
  /** Small label on the answer face. */
  answerLabel?: string;
  /** Fires when a grade button is pressed. */
  onGrade?: (grade: FlashcardGrade) => void;
  /** Controlled flip state. */
  flipped?: boolean;
  defaultFlipped?: boolean;
  onFlipChange?: (flipped: boolean) => void;
  /** Disables the 3D rotation (still flips content). */
  static?: boolean;
}

const GRADES: Array<{
  grade: FlashcardGrade;
  label: string;
  hint: string;
  className: string;
}> = [
  { grade: "again", label: "Again", hint: "<1m", className: "text-destructive hover:bg-destructive/10" },
  { grade: "hard", label: "Hard", hint: "8m", className: "text-warning hover:bg-warning/10" },
  { grade: "good", label: "Good", hint: "1d", className: "text-foreground hover:bg-accent" },
  { grade: "easy", label: "Easy", hint: "4d", className: "text-success hover:bg-success/10" },
];

/**
 * A study flashcard that flips in 3D (rotateY on a preserve-3d stage) between
 * a prompt and its answer. Click, Space, or Enter flips it; the answer face
 * carries spaced-repetition grade buttons. The flip is an interruptible CSS
 * transition — re-triggering mid-flip redirects. Under reduced motion the flip
 * becomes an instant crossfade with no rotation.
 */
export function Flashcard({
  prompt,
  answer,
  promptLabel = "Prompt",
  answerLabel = "Answer",
  onGrade,
  flipped,
  defaultFlipped = false,
  onFlipChange,
  static: isStatic = false,
  className,
  ...props
}: FlashcardProps) {
  const reducedMotion = useReducedMotion();
  const rotate3d = !isStatic && !reducedMotion;

  const [internal, setInternal] = React.useState(defaultFlipped);
  const isFlipped = flipped ?? internal;

  const setFlipped = (next: boolean) => {
    if (flipped === undefined) setInternal(next);
    onFlipChange?.(next);
  };

  return (
    <div
      data-slot="flashcard"
      className={cn("w-full max-w-sm", className)}
      style={{ perspective: rotate3d ? "1200px" : undefined }}
      {...props}
    >
      <div
        role="button"
        tabIndex={0}
        aria-pressed={isFlipped}
        aria-label={isFlipped ? "Show prompt" : "Reveal answer"}
        onClick={() => setFlipped(!isFlipped)}
        onKeyDown={(e) => {
          if (e.key === " " || e.key === "Enter") {
            e.preventDefault();
            setFlipped(!isFlipped);
          }
        }}
        className="relative h-56 w-full cursor-pointer rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
        style={{
          transformStyle: rotate3d ? "preserve-3d" : undefined,
          transform: rotate3d
            ? `rotateY(${isFlipped ? 180 : 0}deg)`
            : undefined,
          transition: rotate3d
            ? "transform 400ms var(--ease-in-out)"
            : undefined,
        }}
      >
        {/* Front face — prompt */}
        <Face
          hidden={rotate3d ? false : isFlipped}
          backface={rotate3d}
          className="items-center justify-center"
        >
          <span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
            {promptLabel}
          </span>
          <div className="mt-3 px-6 text-center text-lg font-medium text-balance">
            {prompt}
          </div>
          <span className="absolute bottom-4 text-xs text-muted-foreground">
            Tap to reveal
          </span>
        </Face>

        {/* Back face — answer */}
        <Face
          hidden={rotate3d ? false : !isFlipped}
          backface={rotate3d}
          rotated={rotate3d}
          className="items-center justify-between py-5"
        >
          <span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
            {answerLabel}
          </span>
          <div className="flex flex-1 items-center px-6 text-center text-lg font-medium text-balance">
            {answer}
          </div>
          <div
            className="grid w-full grid-cols-4 gap-1.5 px-3"
            onClick={(e) => e.stopPropagation()}
          >
            {GRADES.map(({ grade, label, hint, className: gc }) => (
              <button
                key={grade}
                type="button"
                onClick={(e) => {
                  e.stopPropagation();
                  onGrade?.(grade);
                }}
                className={cn(
                  "pressable flex flex-col items-center gap-0.5 rounded-lg border border-border bg-background py-2 text-xs font-medium outline-none transition-[background-color,scale] duration-150 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                  gc,
                )}
              >
                <span>{label}</span>
                <span className="text-[10px] text-muted-foreground tabular-nums">
                  {hint}
                </span>
              </button>
            ))}
          </div>
        </Face>
      </div>
    </div>
  );
}

function Face({
  children,
  hidden,
  backface,
  rotated,
  className,
}: {
  children: React.ReactNode;
  hidden: boolean;
  backface: boolean;
  rotated?: boolean;
  className?: string;
}) {
  return (
    <div
      aria-hidden={hidden}
      className={cn(
        "absolute inset-0 flex flex-col rounded-xl bg-card p-5 shadow-border",
        backface ? "" : hidden ? "invisible" : "visible",
        className,
      )}
      style={{
        backfaceVisibility: backface ? "hidden" : undefined,
        WebkitBackfaceVisibility: backface ? "hidden" : undefined,
        transform: rotated ? "rotateY(180deg)" : undefined,
      }}
    >
      {children}
    </div>
  );
}