Exercise
Interactive exercise block for learning content. Install Exercise from the VLLNT UI registry with the shadcn CLI.
Interactive exercise block for learning content. Part of the learning family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Exercise to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/exercise.jsonSource
"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>
);
}
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.