Flashcard Study Deck
blockeducationAnki and Quizlet style spaced-repetition flashcard study deck with smooth 3D flip card interaction, WCAG 2.2 accessibility curriculum, hint drawer, mastery percentage tracking, keyboard shortcuts, and SM-2 interval rating buttons.
Installation
$pnpm dlx uipkge-ng@latest add flashcard-study-deck$npx uipkge-ng@latest add flashcard-study-deck$yarn dlx uipkge-ng@latest add flashcard-study-deck$bunx uipkge-ng@latest add flashcard-study-deckManifest:
https://uipkge.dev/r/angular/flashcard-study-deck.jsonInstalls to:src/app/components/blocks/flashcard-study-deck/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
Flashcardinterface Flashcard {
id: string
category: string
difficulty?: 'Level A' | 'Level AA' | 'Level AAA' | 'Design System' | 'Engineering'
question: string
hint: string
answer: string
codeSnippet?: string
codeLanguage?: string
source?: string
}Theming
CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--borderFiles installed (2)
src/app/components/blocks/flashcard-study-deck/flashcard-study-deck.component.ts26.3 kB
// Anki and Quizlet style spaced-repetition flashcard study deck with smooth 3D flip card // interaction, WCAG 2.2 accessibility curriculum, hint drawer, mastery percentage tracking, // keyboard shortcuts, and SM-2 interval rating buttons. Port of the React block 1:1. import { ChangeDetectionStrategy, Component, DestroyRef, Input, OnDestroy, OnInit, computed, inject, signal, } from '@angular/core' import { Bookmark, Check, ChevronLeft, ChevronRight, Copy, Flame, GraduationCap, Lightbulb, LucideAngularModule, RotateCw, Shuffle, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent } from '@/ui/card/card.component' import { UiProgressComponent } from '@/ui/progress/progress.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' export interface Flashcard { id: string category: string difficulty?: 'Level A' | 'Level AA' | 'Level AAA' | 'Design System' | 'Engineering' question: string hint: string answer: string codeSnippet?: string codeLanguage?: string source?: string } export type RatingInterval = 'again' | 'hard' | 'good' | 'easy' const DEFAULT_DECK_TITLE = 'Design Engineering & Web Accessibility (WCAG 2.2)' const DEFAULT_CARDS: Flashcard[] = [ { id: 'wcag-1-4-3', category: 'WCAG Criterion 1.4.3', difficulty: 'Level AA', question: 'What is the minimum contrast ratio required for regular body text under WCAG AA standards?', hint: 'Consider standard text (< 18pt or < 14pt bold) versus large display headings (≥ 18pt or ≥ 14pt bold).', answer: '4.5:1 for normal text (< 18pt or < 14pt bold) and 3:1 for large text (≥ 18pt or ≥ 14pt bold).', codeSnippet: 'color: oklch(0.20 0 0); /* on oklch(0.98 0 0) -> 14.2:1 passes AA/AAA */', codeLanguage: 'css', source: 'WCAG 2.2 § 1.4.3 Contrast (Minimum)', }, { id: 'wcag-2-5-8', category: 'WCAG Criterion 2.5.8', difficulty: 'Level AA', question: 'What is the minimum interactive touch target size required in WCAG 2.2 Level AA?', hint: 'Introduced in WCAG 2.2 to prevent accidental taps on mobile touchscreens without requiring 44×44px AAA.', answer: 'At least 24×24 CSS pixels, or have sufficient spacing (undersized targets surrounded by at least 24px diameter clear space).', codeSnippet: '<button class="min-h-6 min-w-6 p-2 touch-manipulation">', codeLanguage: 'html', source: 'WCAG 2.2 § 2.5.8 Target Size (Minimum)', }, { id: 'wcag-2-4-7', category: 'WCAG Criterion 2.4.7', difficulty: 'Level AA', question: 'What visual contrast requirement applies to keyboard focus indicators under WCAG 2.2?', hint: 'Focus indicators must be distinguishable against both the focused element and adjacent background.', answer: 'The focus indicator must have a contrast ratio of at least 3:1 against the unfocused state and surrounding background, with minimum 2px perimeter thickness.', codeSnippet: 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', codeLanguage: 'css', source: 'WCAG 2.2 § 2.4.7 & 2.4.11 Focus Appearance', }, { id: 'wcag-4-1-2', category: 'WCAG Criterion 4.1.2', difficulty: 'Level A', question: 'How should custom collapsible or expandable widgets communicate state to screen readers?', hint: 'Use native semantic elements or explicit ARIA state attributes rather than visual classes alone.', answer: 'Declare explicit ARIA roles (e.g. role="button"), aria-expanded="true|false", and aria-controls linking to the target element ID.', codeSnippet: '<button aria-expanded="true" aria-controls="card-back-content">', codeLanguage: 'html', source: 'WAI-ARIA 1.2 Authoring Practices', }, { id: 'wcag-1-4-11', category: 'WCAG Criterion 1.4.11', difficulty: 'Level AA', question: 'What is the contrast threshold for UI components, boundaries, and graphical objects?', hint: 'Applies to active button borders, input outlines, checkboxes, and state badges.', answer: 'A contrast ratio of at least 3:1 against adjacent colors for user interface components and graphical objects essential for comprehension.', codeSnippet: 'border: 1px solid var(--border); /* >= 3:1 against surface */', codeLanguage: 'css', source: 'WCAG 2.2 § 1.4.11 Non-text Contrast', }, { id: 'oklch-tokens', category: 'Design Systems & Color Science', difficulty: 'Design System', question: 'Why is the OKLCH color space preferred over HSL for dark mode and accessibility contrast derivation?', hint: 'Perceptual lightness is uniform across all hues in OKLCH, unlike HSL where yellow looks brighter than blue at L=50%.', answer: 'OKLCH is perceptually uniform. Equal changes in lightness (L) yield identical perceived contrast regardless of hue or chroma, preventing washed-out dark mode shifts.', codeSnippet: '--color-primary: oklch(0.62 0.22 264.4);', codeLanguage: 'css', source: 'CSS Color Module Level 4', }, ] @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-flashcard-study-deck, [ui-flashcard-study-deck]', standalone: true, // React renders the root <div> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiProgressComponent, UiSeparatorComponent, ], template: ` <div data-slot="flashcard-study-deck" [class]="rootClass"> <!-- Header Section --> <header class="space-y-4"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="space-y-1"> <div class="flex items-center gap-2"> <span ui-badge variant="secondary" class="gap-1 px-2.5 py-0.5 font-medium"> <lucide-icon [img]="GraduationCap" class="text-primary size-3.5" /> <span>Study Deck</span> </span> <span ui-badge variant="outline" class="text-muted-foreground gap-1 text-xs"> <lucide-icon [img]="Flame" class="text-warning size-3" /> <span>5-day streak</span> </span> </div> <h2 class="text-foreground text-xl font-bold tracking-tight md:text-2xl">{{ deckTitle }}</h2> </div> <div class="flex items-center gap-2"> <span ui-badge variant="outline" class="px-3 py-1 font-mono text-xs font-medium"> Card {{ currentNumber() }} of {{ totalCards() }} · {{ masteryPercentage() }}% Mastery </span> </div> </div> <!-- Progress Track --> <div class="space-y-1.5"> <div ui-progress [value]="progressPercentage()" class="h-2"></div> <div class="text-muted-foreground flex items-center justify-between text-xs"> <span>Progress: {{ currentNumber() }} / {{ totalCards() }} cards</span> <span>{{ reviewedCount() }} of {{ totalCards() }} reviewed</span> </div> </div> </header> <!-- 3D Interactive Flashcard Container --> @if (currentCard(); as card) { <div class="mx-auto w-full max-w-2xl [perspective:1200px]"> <div [class]="flipClass"> <!-- FRONT FACE: Question State --> <div ui-card [class]="frontCardClass"> <!-- Front Header --> <div class="flex items-center justify-between gap-3"> <div class="flex flex-wrap items-center gap-2"> <span ui-badge variant="secondary" class="text-xs font-medium">{{ card.category }}</span> @if (card.difficulty) { <span ui-badge variant="outline" class="text-xs">{{ card.difficulty }}</span> } </div> <div class="flex items-center gap-1.5"> <button ui-button variant="ghost" size="icon-sm" [class]="bookmarkBtnClass" aria-label="Bookmark card" (click)="toggleBookmark($event)" > <lucide-icon [img]="Bookmark" [class]="bookmarkIconClass" /> </button> </div> </div> <!-- Question Content --> <div class="my-auto space-y-4 py-4"> <div class="space-y-1.5"> <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase"> Question Prompt </span> <p class="text-foreground text-lg leading-snug font-semibold tracking-tight md:text-xl"> {{ card.question }} </p> </div> <!-- Hint Drawer / Toggle --> @if (card.hint) { <div class="pt-2"> <button ui-button variant="ghost" size="xs" class="text-muted-foreground hover:text-foreground gap-1.5 text-xs" (click)="toggleHint($event)" > <lucide-icon [img]="Lightbulb" class="text-warning size-3.5" /> <span>{{ showHint() ? 'Hide Hint' : 'Show Hint' }}</span> <span class="text-muted-foreground/60 font-mono">[H]</span> </button> @if (showHint()) { <div class="text-muted-foreground border-warning/20 bg-warning/5 mt-2.5 flex items-start gap-2 rounded-lg border p-3 text-xs leading-relaxed" > <lucide-icon [img]="Lightbulb" class="text-warning mt-0.5 size-4 shrink-0" /> <div> <strong class="text-foreground font-semibold">Hint: </strong> <span>{{ card.hint }}</span> </div> </div> } </div> } </div> <!-- Front Footer / Flip Trigger --> <div class="space-y-3 pt-2"> <div ui-separator></div> <div class="text-muted-foreground flex flex-col items-center justify-between gap-3 text-xs sm:flex-row"> <span class="hidden sm:inline" >Press <kbd class="bg-muted border-border rounded border px-1 py-0.5 font-mono">Space</kbd> or click button</span > <button ui-button variant="default" size="sm" class="w-full cursor-pointer gap-2 font-medium sm:w-auto" (click)="toggleFlip($event)" > <lucide-icon [img]="RotateCw" class="size-3.5" /> <span>Flip Card to Reveal Answer</span> </button> </div> </div> </div> <!-- BACK FACE: Answer State --> <div ui-card [class]="backCardClass"> <!-- Back Header --> <div class="flex items-center justify-between gap-3"> <div class="flex flex-wrap items-center gap-2"> <span ui-badge variant="secondary" class="text-xs font-medium">{{ card.category }}</span> <span ui-badge variant="default" class="border-success/30 bg-success/15 text-success text-xs"> Answer Revealed </span> </div> <button ui-button variant="ghost" size="xs" class="text-muted-foreground hover:text-foreground gap-1 text-xs" (click)="toggleFlip($event)" > <lucide-icon [img]="RotateCw" class="size-3.5" /> <span>Flip Back</span> </button> </div> <!-- Answer Content --> <div class="my-auto space-y-4 overflow-y-auto py-3"> <div class="space-y-1.5"> <span class="text-primary text-xs font-semibold tracking-wider uppercase"> Answer Explanation </span> <p class="text-foreground text-base leading-relaxed font-medium md:text-lg">{{ card.answer }}</p> </div> <!-- Code / Token Example Block --> @if (card.codeSnippet) { <div class="border-border bg-muted/60 dark:bg-muted/30 space-y-2 rounded-lg border p-3.5"> <div class="text-muted-foreground flex items-center justify-between text-xs"> <span class="font-mono font-medium">Code / Token Example</span> <button ui-button variant="ghost" size="xs" class="h-6 gap-1 px-2 text-xs" (click)="copyCodeSnippet($event)" > @if (copiedSnippet()) { <lucide-icon [img]="Check" class="text-success size-3" /> } @else { <lucide-icon [img]="Copy" class="size-3" /> } <span>{{ copiedSnippet() ? 'Copied' : 'Copy' }}</span> </button> </div> <pre class="text-foreground bg-background/80 border-border/60 overflow-x-auto rounded border p-2 font-mono text-xs md:text-sm" ><code>{{ card.codeSnippet }}</code></pre> @if (card.source) { <p class="text-muted-foreground text-xs italic">Source: {{ card.source }}</p> } </div> } </div> <!-- Spaced-Repetition Feedback Buttons --> <div class="space-y-2.5 pt-2"> <div class="text-muted-foreground flex items-center justify-between text-xs"> <span class="font-medium">Rate recall difficulty (SM-2 Spaced Repetition):</span> @if (lastRatingFeedback()) { <span class="text-primary text-xs font-medium">{{ lastRatingFeedback() }}</span> } </div> <div class="grid grid-cols-2 gap-2 sm:grid-cols-4"> <button ui-button variant="outline" size="sm" class="border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/20 text-destructive flex h-auto flex-col py-2 text-xs active:scale-95" (click)="handleRate('again', $event)" > <span class="font-semibold">Again</span> <span class="text-xs opacity-80">< 1 min [1]</span> </button> <button ui-button variant="outline" size="sm" class="border-warning/30 bg-warning/10 text-warning hover:bg-warning/20 text-warning flex h-auto flex-col py-2 text-xs active:scale-95" (click)="handleRate('hard', $event)" > <span class="font-semibold">Hard</span> <span class="text-xs opacity-80">12 hours [2]</span> </button> <button ui-button variant="outline" size="sm" class="border-info/30 bg-info/10 text-info hover:bg-info/20 text-info flex h-auto flex-col py-2 text-xs active:scale-95" (click)="handleRate('good', $event)" > <span class="font-semibold">Good</span> <span class="text-xs opacity-80">2 days [3]</span> </button> <button ui-button variant="outline" size="sm" class="border-success/30 bg-success/10 text-success hover:bg-success/20 text-success flex h-auto flex-col py-2 text-xs active:scale-95" (click)="handleRate('easy', $event)" > <span class="font-semibold">Easy</span> <span class="text-xs opacity-80">4 days [4]</span> </button> </div> </div> </div> </div> </div> <!-- Deck Navigation & Utility Controls --> <div class="mx-auto flex max-w-2xl flex-col items-center justify-between gap-3 pt-2 sm:flex-row"> <div class="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start"> <button ui-button variant="outline" size="sm" [disabled]="currentIndex() === 0" class="gap-1.5" (click)="handlePrev()" > <lucide-icon [img]="ChevronLeft" class="size-4" /> <span>Previous</span> </button> <button ui-button variant="outline" size="sm" class="gap-1.5" (click)="handleShuffle()"> <lucide-icon [img]="Shuffle" class="size-3.5" /> <span>Shuffle</span> </button> </div> <div class="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-end"> <button ui-button variant="secondary" size="sm" class="gap-1.5" (click)="toggleFlip($event)"> <lucide-icon [img]="RotateCw" class="size-3.5" /> <span>{{ isFlipped() ? 'Show Question' : 'Flip Card' }}</span> </button> <button ui-button variant="default" size="sm" [disabled]="currentIndex() === totalCards() - 1" class="gap-1.5" (click)="handleNext()" > <span>Next</span> <lucide-icon [img]="ChevronRight" class="size-4" /> </button> </div> </div> <!-- Keyboard Shortcuts Legend Footer --> <div class="mx-auto max-w-2xl pt-2 text-center"> <p class="text-muted-foreground font-mono text-xs"> Shortcuts: <span class="text-foreground font-semibold">← / →</span> Navigate · <span class="text-foreground font-semibold">Space</span> Flip · <span class="text-foreground font-semibold">H</span> Hint · <span class="text-foreground font-semibold">1-4</span> Rate Interval </p> </div> } </div> `, }) export class UiFlashcardStudyDeckComponent implements OnInit, OnDestroy { protected readonly Bookmark = Bookmark protected readonly Check = Check protected readonly ChevronLeft = ChevronLeft protected readonly ChevronRight = ChevronRight protected readonly Copy = Copy protected readonly Flame = Flame protected readonly GraduationCap = GraduationCap protected readonly Lightbulb = Lightbulb protected readonly RotateCw = RotateCw protected readonly Shuffle = Shuffle @Input() initialTitle?: string @Input() initialCards?: Flashcard[] @Input('class') className?: string readonly deck = signal<Flashcard[]>(DEFAULT_CARDS.map((c) => ({ ...c }))) readonly currentIndex = signal(0) readonly isFlipped = signal(false) readonly showHint = signal(false) readonly copiedSnippet = signal(false) readonly bookmarkedCards = signal<Set<string>>(new Set()) readonly cardScores = signal<Record<string, number>>({}) readonly lastRatingFeedback = signal<string | null>(null) private timers: ReturnType<typeof setTimeout>[] = [] private onKeyDown = (event: KeyboardEvent): void => this.handleKeyDown(event) constructor() { inject(DestroyRef).onDestroy(() => { for (const t of this.timers) clearTimeout(t) if (typeof window !== 'undefined') window.removeEventListener('keydown', this.onKeyDown) }) } ngOnInit(): void { if (this.initialCards) this.deck.set(this.initialCards.map((c) => ({ ...c }))) if (typeof window !== 'undefined') window.addEventListener('keydown', this.onKeyDown) } ngOnDestroy(): void { if (typeof window !== 'undefined') window.removeEventListener('keydown', this.onKeyDown) } readonly currentCard = computed(() => this.deck()[this.currentIndex()] ?? this.deck()[0]) readonly totalCards = computed(() => this.deck().length) readonly currentNumber = computed(() => this.currentIndex() + 1) readonly progressPercentage = computed(() => Math.round((this.currentNumber() / this.totalCards()) * 100)) readonly reviewedCount = computed(() => Object.keys(this.cardScores()).length) readonly masteryPercentage = computed(() => { const keys = Object.keys(this.cardScores()) if (keys.length === 0) return 0 const total = keys.reduce((sum, key) => sum + (this.cardScores()[key] ?? 0), 0) return Math.min(100, Math.round(total / this.totalCards())) }) readonly isBookmarked = computed(() => { const card = this.currentCard() return card ? this.bookmarkedCards().has(card.id) : false }) get deckTitle(): string { return this.initialTitle ?? DEFAULT_DECK_TITLE } get rootClass(): string { return cn('mx-auto w-full max-w-4xl space-y-6 p-4 md:p-6 lg:p-8', this.className) } get flipClass(): string { return cn( 'relative min-h-[440px] w-full rounded-xl transition-transform duration-500 [transform-style:preserve-3d] md:min-h-[460px]', this.isFlipped() && '[transform:rotateY(180deg)]', ) } get frontCardClass(): string { return cn( 'border-border bg-card absolute inset-0 flex h-full w-full flex-col justify-between rounded-xl border p-6 shadow-xs [backface-visibility:hidden] md:p-8', this.isFlipped() ? 'pointer-events-none opacity-0' : 'pointer-events-auto opacity-100', ) } get backCardClass(): string { return cn( 'border-border bg-card absolute inset-0 flex h-full w-full [transform:rotateY(180deg)] flex-col justify-between rounded-xl border p-6 shadow-xs [backface-visibility:hidden] md:p-8', this.isFlipped() ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0', ) } get bookmarkBtnClass(): string { return cn('text-muted-foreground hover:text-foreground', this.isBookmarked() && 'text-warning') } get bookmarkIconClass(): string { return cn('size-4', this.isBookmarked() && 'fill-warning text-warning') } toggleFlip(event?: Event): void { event?.stopPropagation() this.isFlipped.update((v) => !v) } toggleHint(event?: Event): void { event?.stopPropagation() this.showHint.update((v) => !v) } toggleBookmark(event?: Event): void { event?.stopPropagation() const card = this.currentCard() if (!card) return this.bookmarkedCards.update((prev) => { const next = new Set(prev) if (next.has(card.id)) next.delete(card.id) else next.add(card.id) return next }) } handlePrev(): void { if (this.currentIndex() > 0) { this.currentIndex.update((i) => i - 1) this.isFlipped.set(false) this.showHint.set(false) this.lastRatingFeedback.set(null) } } handleNext(): void { if (this.currentIndex() < this.totalCards() - 1) { this.currentIndex.update((i) => i + 1) this.isFlipped.set(false) this.showHint.set(false) this.lastRatingFeedback.set(null) } } handleShuffle(): void { const shuffled = [...this.deck()] for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) ;[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!] } this.deck.set(shuffled) this.currentIndex.set(0) this.isFlipped.set(false) this.showHint.set(false) this.lastRatingFeedback.set('Deck shuffled') this.timers.push( setTimeout(() => { if (this.lastRatingFeedback() === 'Deck shuffled') this.lastRatingFeedback.set(null) }, 2000), ) } handleRate(interval: RatingInterval, event?: Event): void { event?.stopPropagation() const card = this.currentCard() if (!card) return const scoreMap: Record<RatingInterval, { score: number; label: string }> = { again: { score: 25, label: 'Again (< 1 min)' }, hard: { score: 50, label: 'Hard (12 hours)' }, good: { score: 80, label: 'Good (2 days)' }, easy: { score: 100, label: 'Easy (4 days)' }, } const { score, label } = scoreMap[interval] this.cardScores.update((prev) => ({ ...prev, [card.id]: score })) this.lastRatingFeedback.set(`Rated: ${label}`) this.timers.push( setTimeout(() => { if (this.currentIndex() < this.totalCards() - 1) this.handleNext() else { this.isFlipped.set(false) this.showHint.set(false) } }, 350), ) } async copyCodeSnippet(event?: Event): Promise<void> { event?.stopPropagation() const snippet = this.currentCard()?.codeSnippet if (!snippet) return try { await navigator.clipboard.writeText(snippet) this.copiedSnippet.set(true) this.timers.push(setTimeout(() => this.copiedSnippet.set(false), 2000)) } catch { // Clipboard permission fallback } } private handleKeyDown(event: KeyboardEvent): void { const target = event.target as HTMLElement | null if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return if (event.key === 'ArrowLeft') { event.preventDefault() this.handlePrev() } else if (event.key === 'ArrowRight') { event.preventDefault() this.handleNext() } else if (event.key === ' ' || event.code === 'Space') { event.preventDefault() this.toggleFlip() } else if (event.key.toLowerCase() === 'h') { event.preventDefault() this.toggleHint() } else if (this.isFlipped()) { if (event.key === '1') { event.preventDefault() this.handleRate('again') } else if (event.key === '2') { event.preventDefault() this.handleRate('hard') } else if (event.key === '3') { event.preventDefault() this.handleRate('good') } else if (event.key === '4') { event.preventDefault() this.handleRate('easy') } } } }src/app/components/blocks/flashcard-study-deck/index.ts0.1 kB
export { UiFlashcardStudyDeckComponent, type Flashcard, type RatingInterval } from './flashcard-study-deck.component'
Raw manifest:https://uipkge.dev/r/angular/flashcard-study-deck.json