Content Intro

Introductory section for content pages with title and description.

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/content-intro.json

Storybook

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

View in Storybook

2 stories available:

Code

"use client"; import { memo, useEffect, useRef } from "react"; import type { ReactNode } from "react"; import type { HeadingTag } from "../../lib/types"; import { cn } from "../../lib/utils"; import { Button } from "../button/button"; export type ContentIntroSection = { id: string; title: string; }; export type ContentIntroLabels = { continueLabel?: string; startLabel?: string; tableOfContentsLabel?: string; }; export type ContentIntroProps = { /** Extra content sections (share, profile, etc.) */ additionalContent?: ReactNode; /** Completed section IDs */ completedSections: Set<string>; /** Estimated time to complete */ estimatedTime: string; /** Is loading progress */ isLoading?: boolean; /** Labels for i18n */ labels?: ContentIntroLabels; /** Callback when navigating to section */ onGoToSection: (index: number) => void; /** Callback when starting */ onStart: () => void; /** Render function for intro content */ renderIntroContent: () => ReactNode; /** Sections for TOC */ sections: ContentIntroSection[]; /** Intro section title */ title: string; /** Heading tag for the main title. Defaults to `h2`. */ titleAs?: HeadingTag; /** Heading tag for the table-of-contents label. Defaults to `h3`. */ tocLabelAs?: HeadingTag; }; const DEFAULT_LABELS: Required<ContentIntroLabels> = { continueLabel: "Continue Tutorial", startLabel: "Start Tutorial", tableOfContentsLabel: "Table of Contents", }; const EMPTY_CONTENT_INTRO_LABELS: ContentIntroLabels = {}; // eslint-disable-next-line max-lines-per-function -- Complex intro with TOC and sticky button function ContentIntroImpl({ additionalContent, completedSections, estimatedTime, isLoading = false, labels = EMPTY_CONTENT_INTRO_LABELS, onGoToSection, onStart, renderIntroContent, sections, title, titleAs: TitleHeading = "h2", tocLabelAs: TocHeading = "h3", }: ContentIntroProps): React.ReactNode { const mergedLabels = { ...DEFAULT_LABELS, ...labels }; const hasProgress = completedSections.size > 0; const onStartRef = useRef(onStart); useEffect(() => { onStartRef.current = onStart; }, [onStart]); useEffect(() => { const onDocumentKeyDown = (event: KeyboardEvent) => { if (event.key === "Enter") { event.preventDefault(); onStartRef.current(); } }; document.addEventListener("keydown", onDocumentKeyDown); return () => { document.removeEventListener("keydown", onDocumentKeyDown); }; }, []); return ( <> <div className="animate-in fade-in-0 duration-500 pb-24"> {/* Introduction Content */} <section className="py-6"> <TitleHeading className="text-2xl md:text-3xl font-semibold mb-6"> {title} </TitleHeading> <div className={cn("max-w-none", "[&_h2:first-of-type]:hidden")}> {renderIntroContent()} </div> </section> {/* Table of Contents */} <section className="mt-8 py-6 border-t border-border"> <TocHeading className="text-lg font-semibold mb-4"> {mergedLabels.tableOfContentsLabel} </TocHeading> <ol className="space-y-2"> {sections.map((section, index) => { const isCompleted = !isLoading && completedSections.has(section.id); return ( <li key={section.id}> <button className="w-full flex items-center gap-3 p-2 -m-2 rounded-lg hover:bg-muted/50 transition-colors text-left" onClick={() => { onGoToSection(index); }} type="button" > <span className={cn( "flex-shrink-0 size-6 rounded-full flex items-center justify-center text-xs font-medium tabular-nums transition-colors", isLoading && "animate-pulse bg-muted", !isLoading && isCompleted && "bg-foreground text-background", !isLoading && !isCompleted && "bg-muted", )} > {isCompleted ? ( <svg className="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> </svg> ) : ( index + 1 )} </span> <span className={cn( "text-sm", isCompleted && "line-through text-muted-foreground", )} > {section.title} </span> </button> </li> ); })} </ol> </section> {/* Extra Content (Share, Profile, etc.) */} {additionalContent} </div> {/* Sticky Start/Continue Button */} <div className="fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background/80 backdrop-blur-sm safe-bottom"> <div className="mx-auto max-w-3xl p-4"> <div className="flex items-center justify-between gap-4"> <p className="text-sm text-muted-foreground hidden sm:block"> {hasProgress ? `${completedSections.size}/${sections.length} completed` : `${sections.length} sections · ${estimatedTime}`} </p> <Button className="flex-1 sm:flex-none px-8 py-6 text-lg font-medium gap-2" onClick={onStart} size="lg" > <svg className="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="M5 3l14 9-14 9V3z" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> </svg> <span> {hasProgress ? mergedLabels.continueLabel : mergedLabels.startLabel} </span> <kbd className="hidden md:inline-flex ml-1 px-1.5 py-0.5 text-xs font-mono bg-primary-foreground/20 rounded"> </kbd> </Button> </div> </div> </div> </> ); } export const ContentIntro = memo(ContentIntroImpl); ContentIntro.displayName = "ContentIntro";

Dependencies

  • @vllnt/ui@^0.2.1