Quiz.svelte
1 <script lang="ts"> 2 import { animate } from "motion"; 3 4 interface Option { 5 id: string; 6 text: string; 7 isCorrect: boolean; 8 } 9 10 interface QuizResponse { 11 title: string; 12 message: string; 13 } 14 15 interface Props { 16 question: string; 17 options: Option[]; 18 correctResponse?: QuizResponse; 19 errorResponse?: QuizResponse; 20 } 21 22 let { question, options, correctResponse, errorResponse }: Props = $props(); 23 24 const defaultCorrectResponse: QuizResponse = { 25 title: "Correct!", 26 message: "You got it right! Nice job.", 27 }; 28 29 const defaultErrorResponse: QuizResponse = { 30 title: "Not quite!", 31 message: "That's not the right answer. Give it another try!", 32 }; 33 34 let correctMsg = $derived(correctResponse ?? defaultCorrectResponse); 35 let errorMsg = $derived(errorResponse ?? defaultErrorResponse); 36 37 let selectedOption = $state<string | null>(null); 38 let hasAnswered = $state(false); 39 let showExplanation = $state(false); 40 41 let optionRefs: Record<string, HTMLButtonElement> = $state({}); 42 let cardRef: HTMLDivElement; 43 44 function handleSelect(option: Option) { 45 if (hasAnswered) return; 46 47 selectedOption = option.id; 48 hasAnswered = true; 49 50 const prefersReduced = window.matchMedia( 51 "(prefers-reduced-motion: reduce)", 52 ).matches; 53 54 if (option.isCorrect) { 55 // Satisfying correct animation 56 const btn = optionRefs[option.id]; 57 if (btn && !prefersReduced) { 58 // Scale up with bounce 59 animate( 60 btn, 61 { scale: [1, 1.05, 1] }, 62 { duration: 0.4, ease: [0.34, 1.56, 0.64, 1] }, 63 ); 64 // Green glow pulse 65 animate( 66 btn, 67 { 68 boxShadow: [ 69 "0 0 0 0 rgba(74, 222, 128, 0)", 70 "0 0 0 12px rgba(74, 222, 128, 0.2)", 71 "0 0 0 4px rgba(74, 222, 128, 0.3)", 72 ], 73 }, 74 { duration: 0.6, ease: "easeOut" }, 75 ); 76 } 77 } else { 78 // Scary error animation 79 const btn = optionRefs[option.id]; 80 if (btn && !prefersReduced) { 81 // Violent shake 82 animate( 83 btn, 84 { x: [0, -8, 8, -8, 8, -4, 4, -2, 2, 0] }, 85 { duration: 0.5, ease: "easeInOut" }, 86 ); 87 // Red flash 88 animate( 89 btn, 90 { 91 backgroundColor: ["#fff", "#fee2e2", "#fff"], 92 borderColor: ["#f9a8d4", "#ef4444", "#f9a8d4"], 93 }, 94 { duration: 0.4 }, 95 ); 96 } 97 98 // Also animate the whole card slightly 99 if (cardRef && !prefersReduced) { 100 animate( 101 cardRef, 102 { x: [0, -4, 4, -4, 4, 0] }, 103 { duration: 0.4, ease: "easeInOut" }, 104 ); 105 } 106 } 107 108 // Show explanation after a brief delay 109 setTimeout(() => { 110 showExplanation = true; 111 }, 300); 112 } 113 114 function reset() { 115 selectedOption = null; 116 hasAnswered = false; 117 showExplanation = false; 118 } 119 </script> 120 121 <div 122 bind:this={cardRef} 123 class="my-8 p-4 rounded-xl border-[0.5px] border-pink-200/60 bg-linear-to-bl from-white/70 to-pink-50/20 backdrop-blur-sm shadow-card" 124 role="region" 125 aria-label="Quiz" 126 > 127 <p class="font-body text-pink-950/90 mb-3! leading-relaxed"> 128 {@html question} 129 </p> 130 131 <div class="space-y-2.5" role="radiogroup" aria-label="Answer options"> 132 {#each options as option (option.id)} 133 <button 134 type="button" 135 bind:this={optionRefs[option.id]} 136 class="w-full text-left p-2 rounded-lg border transition-all duration-200 font-body text-sm 137 {!hasAnswered 138 ? 'border-dashed border-pink-300 bg-white/60 hover:bg-pink-100/50 hover:border-pink-400 cursor-pointer' 139 : option.isCorrect 140 ? 'border-2 border-green-400 bg-green-50/70 cursor-default' 141 : selectedOption === option.id 142 ? 'border-2 border-red-300 bg-red-50/50 cursor-default' 143 : 'border-dashed border-pink-200/50 bg-white/40 opacity-60 cursor-default'}" 144 onclick={() => handleSelect(option)} 145 disabled={hasAnswered} 146 aria-checked={selectedOption === option.id} 147 role="radio" 148 > 149 <div class="flex items-center gap-3"> 150 <span 151 class="shrink-0 w-6 h-6 rounded-full border flex items-center justify-center text-xs font-bold 152 {!hasAnswered 153 ? 'border-pink-300 text-pink-400' 154 : option.isCorrect 155 ? 'border-green-500 bg-green-500 text-white' 156 : selectedOption === option.id 157 ? 'border-red-400 bg-red-400 text-white' 158 : 'border-pink-200 text-pink-300'}" 159 > 160 {#if hasAnswered} 161 {#if option.isCorrect} 162 ✓ 163 {:else if selectedOption === option.id} 164 ✕ 165 {:else} 166 {String.fromCharCode( 167 65 + options.indexOf(option), 168 )} 169 {/if} 170 {:else} 171 {String.fromCharCode(65 + options.indexOf(option))} 172 {/if} 173 </span> 174 <span 175 class={hasAnswered && option.isCorrect 176 ? "text-green-800 font-medium" 177 : hasAnswered && 178 selectedOption === option.id && 179 !option.isCorrect 180 ? "text-red-700" 181 : "text-pink-950/80"} 182 > 183 {option.text} 184 </span> 185 </div> 186 </button> 187 {/each} 188 </div> 189 190 {#if showExplanation} 191 <div 192 class="mt-5 p-4 border rounded-lg {selectedOption && 193 options.find((o) => o.id === selectedOption)?.isCorrect 194 ? 'bg-green-50/70 border-green-400' 195 : 'bg-red-50/50 border-red-400'}" 196 > 197 <p 198 class="font-body text-sm leading-relaxed mb-0! {selectedOption && 199 options.find((o) => o.id === selectedOption)?.isCorrect 200 ? 'text-green-800' 201 : 'text-red-800'}" 202 > 203 {#if selectedOption && options.find((o) => o.id === selectedOption)?.isCorrect} 204 <span class="font-semibold">{correctMsg.title}</span> 205 {@html correctMsg.message} 206 {:else} 207 <span class="font-semibold">{errorMsg.title}</span> 208 {@html errorMsg.message} 209 {/if} 210 </p> 211 {#if selectedOption && !options.find((o) => o.id === selectedOption)?.isCorrect} 212 <button 213 type="button" 214 class="mt-2 text-xs font-body text-pink-600 hover:text-pink-800 underline underline-offset-2 cursor-pointer" 215 onclick={reset} 216 > 217 Try again 218 </button> 219 {/if} 220 </div> 221 {/if} 222 </div>