A study card that flips in 3D between prompt and answer, with spaced-repetition grade buttons on the back.
npx shadcn@latest add @paragon/flashcard"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>
);
}