Exercise

Interactive exercise block for learning content.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/exercise.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

"use client"; import { useState } from "react"; import { Check, Dumbbell, Eye, EyeOff } from "lucide-react"; import type { ReactNode } from "react"; import type { HeadingTag } from "../../lib/types"; import { cn } from "../../lib/utils"; import { Button } from "../button/button"; const difficultyConfig = { easy: { className: "text-green-600 dark:text-green-400", label: "Easy" }, hard: { className: "text-red-600 dark:text-red-400", label: "Hard" }, medium: { className: "text-amber-600 dark:text-amber-400", label: "Medium" }, }; type HeaderProps = { completed: boolean; config: { className: string; label: string }; Heading: HeadingTag; onToggle: () => void; title: string; }; function ExerciseHeader({ completed, config, Heading, onToggle, title, }: HeaderProps) { return ( <div className="flex items-start justify-between gap-4 mb-4"> <div className="flex items-center gap-3"> <div className="flex size-10 items-center justify-center rounded-full bg-primary/10"> <Dumbbell className="size-5 text-primary" /> </div> <div> <Heading className="font-semibold text-foreground">{title}</Heading> <span className={cn("text-xs font-medium", config.className)}> {config.label} </span> </div> </div> <Button className={cn(completed && "bg-green-500 hover:bg-green-600")} onClick={onToggle} size="sm" variant={completed ? "default" : "outline"} > {completed ? ( <> <Check className="size-4 mr-1" /> Done </> ) : ( "Mark Complete" )} </Button> </div> ); } type HintProps = { hint: string; onShow: () => void; showHint: boolean }; function ExerciseHint({ hint, onShow, showHint }: HintProps) { return ( <div className="mb-4"> {showHint ? ( <div className="p-3 rounded bg-muted/50 text-sm"> <p className="font-medium text-xs uppercase tracking-wider text-muted-foreground mb-1"> Hint </p> <p className="text-muted-foreground italic">{hint}</p> </div> ) : ( <button className="text-sm text-primary hover:underline" onClick={onShow} type="button" > Need a hint? </button> )} </div> ); } type SolutionProps = { onToggle: () => void; showSolution: boolean; solution: ReactNode; }; function ExerciseSolution({ onToggle, showSolution, solution }: SolutionProps) { return ( <div> <Button className="mb-3" onClick={onToggle} size="sm" variant="outline"> {showSolution ? ( <> <EyeOff className="size-4 mr-1" /> Hide Solution </> ) : ( <> <Eye className="size-4 mr-1" /> Show Solution </> )} </Button> {showSolution ? ( <div className="p-4 rounded-lg bg-card border text-sm [&>pre]:my-0"> <p className="font-medium text-xs uppercase tracking-wider text-muted-foreground mb-2"> Solution </p> {solution} </div> ) : null} </div> ); } export type ExerciseProps = { /** Heading tag for the exercise title. Defaults to `h4`. */ as?: HeadingTag; children: ReactNode; difficulty?: "easy" | "hard" | "medium"; hint?: string; solution?: ReactNode; title: string; }; export function Exercise({ as: Heading = "h4", children, difficulty = "medium", hint, solution, title, }: ExerciseProps) { const [showSolution, setShowSolution] = useState(false); const [showHint, setShowHint] = useState(false); const [completed, setCompleted] = useState(false); return ( <div className="my-6 rounded-lg border-2 border-dashed border-primary/30 bg-primary/5 p-6"> <ExerciseHeader completed={completed} config={difficultyConfig[difficulty]} Heading={Heading} onToggle={() => { setCompleted(!completed); }} title={title} /> <div className="text-sm text-muted-foreground mb-4 [&>p]:mb-2 [&>ul]:mb-2"> {children} </div> {hint ? ( <ExerciseHint hint={hint} onShow={() => { setShowHint(true); }} showHint={showHint} /> ) : null} {solution ? ( <ExerciseSolution onToggle={() => { setShowSolution(!showSolution); }} showSolution={showSolution} solution={solution} /> ) : null} </div> ); }

Dependencies

  • @vllnt/ui@^0.2.1