shadimage
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 · medium
ui-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} />;
}

More from ericts

Browse all components in this library →