Progress Card

Card displaying progress metrics and status.

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/progress-card.json

Storybook

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

View in Storybook

Code

"use client"; import { memo, useEffect, useState } from "react"; import type { ReactNode } from "react"; import { useMounted } from "../../lib/use-mounted"; import { Badge } from "../badge/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../card"; export type ContentCardProgress = { completedCount: number; totalSections: number; }; export type ContentCardProps = { /** Badge label for difficulty/category */ badgeLabel: string; /** Badge variant */ badgeVariant?: "default" | "destructive" | "outline" | "secondary"; /** Card description */ description: string; /** Function to get progress from storage */ getProgress?: () => ContentCardProgress | null; /** Href for the card link */ href: string; /** Link component to use (e.g., Next.js Link) */ linkComponent?: React.ComponentType<{ children: ReactNode; className?: string; href: string; }>; /** Metadata items (e.g., "30 min", "10 sections") */ metadata?: string[]; /** Progress completed label (e.g., "completed") */ progressLabel?: string; /** Tags to display */ tags?: string[]; /** Card title */ title: string; }; function DefaultLink({ children, className, href, }: { children: ReactNode; className?: string; href: string; }): React.ReactNode { return ( <a className={className} href={href}> {children} </a> ); } const EMPTY_PROGRESS_CARD_LIST: string[] = []; function ContentCardImpl({ badgeLabel, badgeVariant = "default", description, getProgress, href, linkComponent: LinkComponent = DefaultLink, metadata = EMPTY_PROGRESS_CARD_LIST, progressLabel = "completed", tags = EMPTY_PROGRESS_CARD_LIST, title, }: ContentCardProps): React.ReactNode { const [progress, setProgress] = useState<ContentCardProgress | null>(null); const isHydrated = useMounted(); // Load progress after hydration useEffect(() => { if (getProgress) { const result = getProgress(); requestAnimationFrame(() => { setProgress(result); }); } }, [getProgress]); const showProgress = isHydrated && progress && progress.completedCount > 0; return ( <LinkComponent className="block h-full" href={href}> <Card className="h-full flex flex-col hover:shadow-lg transition-shadow cursor-pointer"> <CardHeader> {/* Badge and progress */} <div className="flex items-center gap-2 mb-2"> <Badge className="text-xs capitalize" variant={badgeVariant}> {badgeLabel} </Badge> {showProgress ? ( <span className="text-xs text-muted-foreground"> {progress.completedCount}/{progress.totalSections}{" "} {progressLabel} </span> ) : null} </div> <CardTitle className="line-clamp-2 text-lg">{title}</CardTitle> <CardDescription className="line-clamp-3"> {description} </CardDescription> </CardHeader> <CardContent className="mt-auto space-y-2"> {/* Metadata */} {metadata.length > 0 ? ( <div className="flex flex-wrap gap-2 text-xs text-muted-foreground"> {metadata.map((item, index) => ( <span key={item}> {index > 0 ? <span className="mr-2"></span> : null} {item} </span> ))} </div> ) : null} {/* Tags */} {tags.length > 0 ? ( <div className="flex flex-wrap gap-1"> {tags.map((tag) => ( <Badge className="text-xs" key={tag} variant="outline"> {tag} </Badge> ))} </div> ) : null} </CardContent> </Card> </LinkComponent> ); } export const ContentCard = memo(ContentCardImpl); ContentCard.displayName = "ContentCard";

Dependencies

  • @vllnt/ui@^0.2.1