ericts
blockdemo-tabssource: ok · hit
Demo Tabs
A tabbed product demo with a shared playback timeline, progress underline, and customizable scenes. Includes three synchronized terminal-and-result examples.
npx shadcn add "https://ui.ericts.com/r/demo-tabs.json"free
Visual style
3dterminal-mono
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumui-primitives@base-ui/react
animationmotion
iconslucide-react
Registry dependencies
https://ui.ericts.com/r/use-reduced-motion.json
Files2 · source cached 10/14/2026, 6:13:03 PM
registry/base/blocks/demo-tabs.tsx
"use client";
import * as React from "react";
import { Tabs } from "@base-ui/react/tabs";
import {
motion,
motionValue,
useTransform,
type MotionValue,
} from "motion/react";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { cn } from "@/lib/utils";
export type DemoTabsScene = {
/** Shared, linear playback position, from 0 to 1. Use useTransform in a child. */
progress: MotionValue<number>;
isPlaying: boolean;
reducedMotion: boolean;
};
export type DemoTabsItem = {
/** Unique, stable identifier. */
value: string;
label: string;
description?: string;
/** Total scene length in milliseconds, including time to read the final frame. */
duration: number;
render: (scene: DemoTabsScene) => React.ReactNode;
};
export type DemoTabsProps = Omit<
React.ComponentProps<"section">,
"children" | "defaultValue" | "onChange"
> & {
items: readonly DemoTabsItem[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
autoPlay?: boolean;
loop?: boolean;
selectorLabel?: string;
emptyLabel?: string;
classNames?: {
list?: string;
tab?: string;
progress?: string;
panel?: string;
};
};
function ProgressLine({
progress,
className,
}: {
progress: MotionValue<number>;
className?: string;
}) {
const transform = useTransform(progress, (value) => `scaleX(${value})`);
return (
<motion.span
aria-hidden="true"
data-slot="demo-tabs-progress"
className={cn(
"absolute inset-x-0 bottom-0 h-px origin-left bg-foreground",
className,
)}
style={{ transform }}
/>
);
}
/**
* A single clock drives the active scene and its tab underline, without React
* renders on every frame. Scenes should derive ALL motion from progress, not
* start independent timers. Manual selection plays once and disables advancing.
*/
export function DemoTabs({
items,
value,
defaultValue,
onValueChange,
autoPlay = true,
loop = true,
selectorLabel = "Choose a demo",
emptyLabel = "Add a demo to get started.",
classNames,
className,
ref,
onFocusCapture,
onBlurCapture,
...props
}: DemoTabsProps) {
const reducedMotion = useReducedMotion();
const [selected, setSelected] = React.useState(
defaultValue ?? items[0]?.value,
);
const active =
items.find((item) => item.value === (value ?? selected)) ?? items[0];
const activeValue = active?.value;
const duration =
active && Number.isFinite(active.duration) && active.duration > 0
? active.duration
: 6000;
const [replay, setReplay] = React.useState(0);
// A new clock resets both scene and underline atomically, including prop changes.
const progress = React.useMemo(() => {
void activeValue;
void replay;
void duration;
return motionValue(reducedMotion ? 1 : 0);
}, [activeValue, duration, replay, reducedMotion]);
const [completed, setCompleted] = React.useState<MotionValue<number> | null>(
null,
);
const [requestedPlaying, setRequestedPlaying] = React.useState(autoPlay);
const [previousAutoPlay, setPreviousAutoPlay] = React.useState(autoPlay);
if (previousAutoPlay !== autoPlay) {
setPreviousAutoPlay(autoPlay);
setRequestedPlaying(autoPlay);
}
const [manual, setManual] = React.useState(false);
const [inView, setInView] = React.useState(false);
const [visible, setVisible] = React.useState(true);
const [contentFocused, setContentFocused] = React.useState(false);
const [node, setNode] = React.useState<HTMLElement | null>(null);
const mergedRef = React.useCallback(
(element: HTMLElement | null) => {
setNode(element);
if (element && typeof IntersectionObserver === "undefined")
setInView(true);
if (typeof ref === "function") return ref(element);
if (ref) ref.current = element;
},
[ref],
);
const finished = completed === progress;
const isPlaying =
Boolean(active) &&
requestedPlaying &&
inView &&
visible &&
!contentFocused &&
!reducedMotion &&
!finished;
React.useEffect(() => {
if (!node || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(
([entry]) =>
setInView(Boolean(entry?.isIntersecting && entry.intersectionRatio >= 0.2)),
{ threshold: 0.2 },
);
observer.observe(node);
return () => observer.disconnect();
}, [node]);
React.useEffect(() => {
const update = () => setVisible(!document.hidden);
update();
document.addEventListener("visibilitychange", update);
return () => document.removeEventListener("visibilitychange", update);
}, []);
const complete = React.useEffectEvent(() => {
setCompleted(progress);
if (manual || items.length < 2) return;
const index = items.findIndex((item) => item.value === activeValue);
if (index === items.length - 1 && !loop) return;
const next = items[(index + 1) % items.length];
if (value === undefined) setSelected(next.value);
onValueChange?.(next.value);
});
React.useEffect(() => {
if (!isPlaying) return;
let frame = 0;
let last = performance.now();
const tick = (now: number) => {
// Also guard here: a visibility event can precede React's effect cleanup.
if (document.hidden) {
last = now;
frame = requestAnimationFrame(tick);
return;
}
const next = Math.min(
1,
progress.get() + Math.max(0, now - last) / duration,
);
last = now;
progress.set(next);
if (next >= 1) complete();
else frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [isPlaying, progress, duration]);
function select(next: string) {
setManual(true);
setRequestedPlaying(true);
if (next === activeValue) setReplay((count) => count + 1);
if (value === undefined) setSelected(next);
if (next !== activeValue) onValueChange?.(next);
}
return (
<section
{...props}
ref={mergedRef}
data-slot="demo-tabs"
className={cn(
"@container/demo w-full overflow-hidden rounded-xl border bg-background text-foreground",
className,
)}
onFocusCapture={(event) => {
onFocusCapture?.(event);
// Focus alone is not a choice: keyboard users tabbing past the
// tablist keep auto-advance. Only `select` (click, Enter/Space on a
// tab) switches to manual playback.
setContentFocused(
Boolean(
(event.target as HTMLElement).closest(
'[data-slot="demo-tabs-panel"]',
) &&
event.currentTarget.contains(
(event.target as HTMLElement).closest(
'[data-slot="demo-tabs-panel"]',
),
),
),
);
}}
onBlurCapture={(event) => {
onBlurCapture?.(event);
if (!event.currentTarget.contains(event.relatedTarget))
setContentFocused(false);
}}
>
{active ? (
<>
<Tabs.Root
value={active.value}
onValueChange={(next) => {
if (typeof next === "string") select(next);
}}
>
<Tabs.List
aria-label={selectorLabel}
activateOnFocus={false}
className={cn("flex overflow-x-auto border-b", classNames?.list)}
>
{items.map((item) => (
<Tabs.Tab
key={item.value}
value={item.value}
onClick={() => {
if (item.value === activeValue) select(item.value);
}}
className={cn(
"relative flex min-w-24 flex-1 flex-col items-start gap-1 border-r px-5 py-4 text-left last:border-r-0 @min-[40rem]/demo:min-w-44 data-active:bg-muted/50 focus-visible:z-10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring",
classNames?.tab,
)}
>
<span className="font-mono text-xs">{item.label}</span>
{item.description && (
<span className="hidden whitespace-nowrap text-xs text-muted-foreground @min-[40rem]/demo:block">
{item.description}
</span>
)}
{item.value === activeValue && (
<ProgressLine
progress={progress}
className={classNames?.progress}
/>
)}
</Tabs.Tab>
))}
</Tabs.List>
{items.map((item) => (
<Tabs.Panel
data-slot="demo-tabs-panel"
key={item.value}
value={item.value}
className={cn(
"outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
classNames?.panel,
)}
>
{item.value === activeValue && (
<React.Fragment key={`${item.value}-${replay}`}>
{item.render({ progress, isPlaying, reducedMotion })}
</React.Fragment>
)}
</Tabs.Panel>
))}
</Tabs.Root>
</>
) : (
<p className="p-8 text-sm text-muted-foreground">{emptyLabel}</p>
)}
</section>
);
}
registry/base/blocks/demo-tabs-example.tsx
"use client";
import { motion, useTransform, type MotionValue } from "motion/react";
import {
ArrowUpRightIcon,
CheckIcon,
FileCodeIcon,
GlobeIcon,
ShieldCheckIcon,
} from "lucide-react";
import { DemoTabs, type DemoTabsItem, type DemoTabsScene } from "./demo-tabs";
const scripts = {
docs: {
command: "atlas docs --quickstart",
lines: [
"Resolving project context…",
"Found 12 endpoints",
"Generating typed examples",
"Documentation ready.",
],
eyebrow: "YOUR FIRST REQUEST",
title: "From question to working code.",
description: "A small example. Everything you need to start.",
icon: FileCodeIcon,
},
check: {
command: "atlas check --strict",
lines: [
"Reading project configuration…",
"Types verified · 24 modules",
"All 18 checks passed",
"Ready for production.",
],
eyebrow: "CONFIDENCE, BUILT IN",
title: "Catch it before you ship it.",
description: "One check for everything that matters.",
icon: ShieldCheckIcon,
},
deploy: {
command: "atlas deploy --production",
lines: [
"Building production bundle…",
"Uploading assets · 142 kB",
"Propagating to the edge",
"Live at demo.atlas.dev",
],
eyebrow: "A SHORTER PATH TO LIVE",
title: "Local idea. Global release.",
description: "Your next release is one command away.",
icon: GlobeIcon,
},
} as const;
// Both columns read the same MotionValue. No independent delays or timers:
// pause, replay and tab changes stay synchronized, even halfway through a reveal.
function Beat({
progress,
at,
children,
className,
}: {
progress: MotionValue<number>;
at: number;
children: React.ReactNode;
className?: string;
}) {
const opacity = useTransform(progress, [at, at + 0.06], [0, 1]);
const transform = useTransform(
progress,
[at, at + 0.06],
["translateY(6px)", "translateY(0px)"],
);
return (
<motion.div style={{ opacity, transform }} className={className}>
{children}
</motion.div>
);
}
function DemoScene({
kind,
progress,
}: DemoTabsScene & { kind: keyof typeof scripts }) {
const script = scripts[kind];
const Icon = script.icon;
return (
<>
<p className="sr-only">
{script.title} {script.description} {script.lines.at(-1)}
</p>
<div
aria-hidden="true"
className="grid @min-[40rem]/demo:grid-cols-[0.95fr_1.05fr]"
>
<div className="flex min-h-64 flex-col gap-8 border-b p-6 @min-[40rem]/demo:min-h-[25rem] @min-[40rem]/demo:border-r @min-[40rem]/demo:border-b-0 @min-[40rem]/demo:p-8">
<div className="flex items-center justify-between font-mono text-[10px] tracking-widest text-muted-foreground">
<span>TERMINAL</span>
<span>~/project</span>
</div>
<div className="flex flex-col gap-5 font-mono text-xs leading-relaxed">
<div className="flex gap-3">
<span className="text-muted-foreground">$</span>
<span>{script.command}</span>
</div>
<div className="flex flex-col gap-3">
{script.lines.map((line, index) => (
<Beat
key={line}
progress={progress}
at={0.1 + index * 0.18}
className="flex items-center gap-3"
>
<span className="text-muted-foreground">
{index === 3 ? "✓" : "›"}
</span>
<span
className={
index === 3 ? "text-foreground" : "text-muted-foreground"
}
>
{line}
</span>
</Beat>
))}
</div>
</div>
<div className="mt-auto pt-5 font-mono text-[10px] tracking-wide text-muted-foreground">
ATLAS CLI <span className="ml-2 opacity-50">v1.0.0</span>
</div>
</div>
<div className="relative flex min-h-[25rem] flex-col items-center justify-center gap-7 overflow-hidden bg-muted/15 px-6 py-10 @min-[40rem]/demo:px-10">
<div className="flex w-full max-w-72 flex-col gap-3">
<span className="font-mono text-[9px] tracking-[0.16em] text-muted-foreground">
{script.eyebrow}
</span>
<h3 className="max-w-64 text-2xl leading-tight tracking-tight">
{script.title}
</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{script.description}
</p>
</div>
<Beat
progress={progress}
at={0.1}
className="w-full max-w-72 rounded-lg border bg-background shadow-sm"
>
<div className="flex items-center gap-2 border-b px-4 py-3 text-xs">
<Icon className="size-3.5 text-muted-foreground" />
<span>
{kind === "docs"
? "quickstart.ts"
: kind === "check"
? "Project health"
: "Production"}
</span>
</div>
<div className="flex min-h-28 flex-col justify-center gap-3 p-4">
{kind === "docs" ? (
<>
<Beat
progress={progress}
at={0.28}
className="font-mono text-[11px] text-muted-foreground"
>
const client = atlas();
</Beat>
<Beat
progress={progress}
at={0.46}
className="font-mono text-[11px]"
>
await client.hello();
</Beat>
<Beat
progress={progress}
at={0.64}
className="flex items-center gap-2 text-[11px] text-muted-foreground"
>
<CheckIcon className="size-3" /> 200 OK · Hello, world.
</Beat>
</>
) : kind === "check" ? (
["Type safety", "Dependencies", "Build integrity"].map(
(label, index) => (
<Beat
key={label}
progress={progress}
at={0.28 + index * 0.18}
className="flex items-center justify-between text-xs"
>
<span className="text-muted-foreground">{label}</span>
<CheckIcon className="size-3.5" />
</Beat>
),
)
) : (
<>
<Beat
progress={progress}
at={0.28}
className="flex items-center justify-between text-xs"
>
<span className="text-muted-foreground">Build</span>
<span>Complete</span>
</Beat>
<Beat
progress={progress}
at={0.46}
className="flex items-center justify-between text-xs"
>
<span className="text-muted-foreground">Regions</span>
<span>12 / 12</span>
</Beat>
<Beat
progress={progress}
at={0.64}
className="flex items-center justify-between text-xs"
>
<span>demo.atlas.dev</span>
<ArrowUpRightIcon className="size-3.5" />
</Beat>
</>
)}
</div>
</Beat>
</div>
</div>
</>
);
}
const items: readonly DemoTabsItem[] = [
{
value: "docs",
label: "docs",
description: "Find your starting point",
duration: 6500,
render: (scene) => <DemoScene {...scene} kind="docs" />,
},
{
value: "check",
label: "check",
description: "Ship with confidence",
duration: 6000,
render: (scene) => <DemoScene {...scene} kind="check" />,
},
{
value: "deploy",
label: "deploy",
description: "Take it to production",
duration: 6500,
render: (scene) => <DemoScene {...scene} kind="deploy" />,
},
];
export function DemoTabsExample() {
return <DemoTabs items={items} />;
}