# Agency: Reel

> An ink-blue panel with a browser and a rail of projects: the address bar types each client's domain and the site loads line by line.

A shadcn/ui block from the [@eased](https://eased.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/eased/agency-reel).

- Page, with a live preview: https://shadcn.exchange/r/eased/agency-reel
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/eased/agency-reel-light-9c8cad796a99590a.png
- Registry item: https://eased.dev/r/agency-reel.json
- Listed: 2026-10-23, source last changed 2026-10-23

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/eased/agency-reel.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `motion`
- Registry items: `https://eased.dev/r/eased-tokens.json`, `https://eased.dev/r/eased-motion.json`, `https://eased.dev/r/agency-core.json`, `https://eased.dev/r/button.json`, `https://eased.dev/r/text-swap.json`

## Files

### `src/registry/eased/templates/agency/reel.tsx`

```tsx
"use client";

import { AnimatePresence, motion, useInView, useMotionValue, useMotionValueEvent, useTransform, type MotionValue } from "motion/react";
import { useEffect, useId, useRef, useState, useSyncExternalStore, type KeyboardEvent } from "react";
import { Button } from "../../button/button";
import { enter, exit, reducedFade, stagger, useSafeReducedMotion } from "../../lib/motion";
import { TextSwap } from "../../text-swap/text-swap";
import { ClientArt } from "./client-art";
import { brand, projects, reel, type Project } from "./content";
import { COLUMN, Tiles } from "./kit";

/**
 * The signature: a case-study reel. A panel in the studio's ink blue, inset to the page column
 * under the wordmark, holds a browser window and, beside it, a rail of the four projects; the
 * window plays the studio's client sites in turn. For each one the address bar types the
 * client's domain, a load line runs under the toolbar, and the drawn site comes in, its lines
 * rising 80ms apart; it holds, and the next one types. It starts on its own a beat after it is
 * in view, plays the four once and rests on the last, with Replay.
 *
 * Everything is one clock (`clock`, ms) that runs only while the panel is on screen, the tab is
 * visible and the reel is playing, so it pauses itself offscreen and the pause button (rule 85)
 * just stops the clock. The address bar, the page and the tabs' progress lines are all read
 * from the clock. Reduced motion shows one still: the first site, loaded, with the tabs to
 * switch sites and no clock.
 */

const SITES = reel.projects.map((id) => projects.find((p) => p.id === id)!);

/** The blank tab before the first site types: a beat to notice the window. */
const BEAT = 900;
/** One typed character, about a quick typist's pace. */
const CHAR = 55;
/** After the last character, before Return. */
const ENTER = 220;
/** The load line under the toolbar. */
const LOAD = 480;
/** How long each site holds once it has loaded. */
const HOLD = 3400;

/** Each site's span on the clock: typing from `start`, loaded at `loaded`, next site at `end`. */
const SPANS = SITES.reduce<{ start: number; loaded: number; end: number }[]>((spans, site) => {
  const start = spans.at(-1)?.end ?? BEAT;
  const loaded = start + site.domain.length * CHAR + ENTER + LOAD;
  return [...spans, { start, loaded, end: loaded + HOLD }];
}, []);
const TOTAL = SPANS.at(-1)!.end;

/** What the window shows at a time on the clock: the page that is up, and the address bar. */
type Frame = { page: number; typing: number; typed: number; ended: boolean };

function frameAt(t: number): Frame {
  if (t >= TOTAL) return { page: SITES.length - 1, typing: -1, typed: 0, ended: true };
  const i = SPANS.findLastIndex((s) => t >= s.start);
  if (i === -1) return { page: -1, typing: -1, typed: 0, ended: false };
  const span = SPANS[i];
  if (t >= span.loaded) return { page: i, typing: -1, typed: 0, ended: false };
  // Typing (then loading): the previous page stays up until the new one has loaded.
  return { page: i - 1, typing: i, typed: Math.min(SITES[i].domain.length, Math.floor((t - span.start) / CHAR)), ended: false };
}

const sameFrame = (a: Frame, b: Frame) => a.page === b.page && a.typing === b.typing && a.typed === b.typed && a.ended === b.ended;

const subscribeVisibility = (onChange: () => void) => {
  document.addEventListener("visibilitychange", onChange);
  return () => document.removeEventListener("visibilitychange", onChange);
};
const useTabVisible = () => useSyncExternalStore(subscribeVisibility, () => document.visibilityState === "visible", () => true);

/** The reel's clock and controls: it advances in animation frames while `active`. */
function useReel(active: boolean, reduced: boolean) {
  const clock = useMotionValue(0);
  const [frame, setFrame] = useState<Frame>(() => frameAt(0));
  const [playing, setPlaying] = useState(true);

  useMotionValueEvent(clock, "change", (t) => {
    const next = frameAt(t);
    setFrame((prev) => (sameFrame(prev, next) ? prev : next));
  });

  // Reduced motion: one still, the first site loaded, and no clock.
  useEffect(() => {
    if (reduced) clock.set(SPANS[0].loaded);
  }, [reduced, clock]);

  const running = active && playing && !reduced && !frame.ended;
  useEffect(() => {
    if (!running) return;
    let raf = 0;
    let last = performance.now();
    const tick = (now: number) => {
      clock.set(Math.min(TOTAL, clock.get() + (now - last)));
      last = now;
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [running, clock]);

  return {
    clock,
    frame,
    playing,
    togglePlaying: () => setPlaying((p) => !p),
    replay: () => {
      clock.set(0);
      setPlaying(true);
    },
    /** A tab: the site at once, loaded; the reel carries on from there if it is playing. */
    show: (i: number) => clock.set(SPANS[i].loaded),
  };
}

export function Reel() {
  const reduced = useSafeReducedMotion();
  const panel = useRef<HTMLDivElement>(null);
  const inView = useInView(panel, { amount: 0.35 });
  const visible = useTabVisible();
  const { clock, frame, playing, togglePlaying, replay, show } = useReel(inView && visible, reduced);
  const id = useId();
  const current = frame.page >= 0 ? SITES[frame.page] : undefined;
  // The tab marked as selected: the site being typed, else the one that is up.
  const selected = frame.typing >= 0 ? frame.typing : Math.max(frame.page, 0);
  // Arrow keys move between the tabs, Home and End jump to the ends, and each site shows as its tab takes focus (one tab stop).
  const onTabKey = (e: KeyboardEvent<HTMLDivElement>) => {
    const last = SITES.length - 1;
    const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
    const jump = { Home: 0, End: last }[e.key];
    if (step === undefined && jump === undefined) return;
    e.preventDefault();
    const next = jump ?? (selected + step! + SITES.length) % SITES.length;
    show(next);
    document.getElementById(`${id}-tab-${next}`)?.focus();
  };

  return (
    <section id="reel" aria-label={reel.label} className={COLUMN}>
      {/* The studio's ink in both themes; its tokens are the dark theme's, so text and lines read on it. */}
      <div
        ref={panel}
        data-theme="dark"
        className="relative isolate grid gap-pad-md overflow-hidden rounded-surface bg-(--tessera-ink) p-2.5 text-text sm:p-pad-lg lg:grid-cols-[minmax(0,1fr)_15rem] lg:gap-pad-lg lg:p-pad-xl"
      >
        <BrowserWindow frame={frame} clock={clock} panelId={`${id}-page`} tabId={`${id}-tab-${selected}`} reduced={reduced} />
        <div className="flex min-w-0 flex-col gap-pad-md px-1.5 pb-1.5 sm:p-0">
          <div className="flex h-control-sm items-center justify-between gap-in">
            <p className="flex items-center gap-2 text-ui-sm text-text-secondary">
              <Tiles className="size-3.5 text-text" />
              {reel.label}
            </p>
            {!reduced && <PlayControl ended={frame.ended} playing={playing} onToggle={togglePlaying} onReplay={replay} />}
          </div>
          <div role="tablist" onKeyDown={onTabKey} className="grid grid-cols-4 gap-1.5 lg:grid-cols-1 lg:gap-0">
            {SITES.map((site, i) => (
              <ReelTab
                key={site.id}
                id={`${id}-tab-${i}`}
                panelId={`${id}-page`}
                index={i}
                site={site}
                selected={i === selected}
                clock={clock}
                reduced={reduced}
                onSelect={() => show(i)}
              />
            ))}
          </div>
          <Mosaic />
          <div className="flex min-w-0 flex-col gap-1">
            <p className="flex min-w-0 text-ui-md font-medium text-text">
              <TextSwap swapKey={current?.id ?? "start"}>{current?.client ?? reel.start.title}</TextSwap>
            </p>
            <p className="line-clamp-3 min-h-15 text-ui text-pretty text-text-secondary">{current?.line ?? reel.start.line}</p>
          </div>
        </div>
      </div>
    </section>
  );
}

/**
 * The rail's framing: a mosaic of small tiles filling the space under the tabs, fading out
 * upward, one of them in vermilion. Drawn once and still; wide screens only.
 */
function Mosaic() {
  const tiles = Array.from({ length: 8 * 6 }, (_, i) => ({ x: (i % 8) * 14, y: Math.floor(i / 8) * 14, lit: i === 29 }));
  return (
    <svg
      aria-hidden
      viewBox="0 0 110 82"
      className="pointer-events-none mt-auto w-full text-text [mask-image:linear-gradient(to_top,#000_10%,transparent_95%)] max-lg:hidden"
    >
      {tiles.map((t) => (
        <rect key={`${t.x}-${t.y}`} x={t.x} y={t.y} width="11" height="11" rx="1.5" fill={t.lit ? "var(--tessera-tile)" : "currentColor"} fillOpacity={t.lit ? 1 : 0.07} />
      ))}
    </svg>
  );
}

/**
 * Pause and play while the reel runs; Replay once it has rested on the last site. One button
 * whose label swaps, anchored to the end of the rail's first line, so nothing beside it moves
 * when its width does (rule 108).
 */
function PlayControl({ ended, playing, onToggle, onReplay }: { ended: boolean; playing: boolean; onToggle: () => void; onReplay: () => void }) {
  const state = ended ? "replay" : playing ? "pause" : "play";
  const label = { replay: "Replay", pause: "Pause", play: "Play" }[state];
  return (
    <Button variant="ghost" size="sm" edge="end" swapKey={state} onClick={ended ? onReplay : onToggle}>
      {{ replay: <ReplayIcon />, pause: <PauseIcon />, play: <PlayIcon /> }[state]}
      {label}
    </Button>
  );
}

/**
 * One site's tab. In the rail (lg) it is a row: number, client and disciplines, with a 2px
 * vermilion line down its leading edge that fills while its site is on the clock. On narrower
 * screens the tabs sit four across under the window and the line runs along the bottom. Both
 * lines are transforms read from the clock; under reduced motion they mark the selected tab.
 */
function ReelTab({
  id,
  panelId,
  index,
  site,
  selected,
  clock,
  reduced,
  onSelect,
}: {
  id: string;
  panelId: string;
  index: number;
  site: Project;
  selected: boolean;
  clock: MotionValue<number>;
  reduced: boolean;
  onSelect: () => void;
}) {
  const span = SPANS[index];
  const progress = useTransform(clock, [span.start, span.end], [0, 1], { clamp: true });
  const fill = reduced ? (selected ? 1 : 0) : progress;
  return (
    <button
      type="button"
      role="tab"
      id={id}
      aria-selected={selected}
      tabIndex={selected ? 0 : -1}
      aria-controls={panelId}
      onClick={onSelect}
      className={`group focus-ring relative flex min-w-0 cursor-pointer flex-col gap-2 rounded-control-xs pt-1 text-left text-ui-sm transition-colors duration-(--eased-duration-fast) ease-standard lg:gap-0.5 lg:py-2.5 lg:pr-0 lg:pl-pad-base ${
        selected ? "cursor-default text-text" : "text-text-secondary hover-fine:text-text"
      }`}
    >
      <span className="flex min-w-0 gap-1.5">
        <span className="tabular-nums">{String(index + 1).padStart(2, "0")}</span>
        <span className="truncate max-sm:sr-only">{site.client}</span>
      </span>
      <span className="truncate text-ui-xs text-text-muted max-lg:hidden">{site.disciplines.join(", ")}</span>
      <span aria-hidden className="relative h-0.5 overflow-hidden rounded-pill bg-line lg:absolute lg:inset-y-0 lg:left-0 lg:h-auto lg:w-0.5">
        <motion.span className="absolute inset-0 origin-left bg-(--tessera-tile) lg:hidden" style={{ scaleX: fill }} />
        <motion.span className="absolute inset-0 origin-top bg-(--tessera-tile) max-lg:hidden" style={{ scaleY: fill }} />
      </span>
    </button>
  );
}

/** The page's lines rise 8px, 80ms apart, as it loads; the outgoing page fades under it. */
const PAGE = {
  hidden: { opacity: 0 },
  shown: { opacity: 1, transition: { ...enter, staggerChildren: stagger.line, delayChildren: 0.05 } },
  gone: { opacity: 0, transition: exit },
};

/**
 * A plain browser window, drawn: traffic lights, back and forward, the address bar and the page.
 * The page area is 16:10, 4:5 on a phone, and the client sites lay themselves out to fit it.
 */
function BrowserWindow({ frame, clock, panelId, tabId, reduced }: { frame: Frame; clock: MotionValue<number>; panelId: string; tabId: string; reduced: boolean }) {
  const site = frame.page >= 0 ? SITES[frame.page] : undefined;
  return (
    <div className="overflow-hidden rounded-panel border border-line bg-surface shadow-floating [--pad:0px]">
      <div className="relative grid h-11 grid-cols-[1fr_minmax(0,28rem)_1fr] items-center gap-3 border-b border-line-subtle px-3.5">
        <div className="flex items-center gap-4">
          <span aria-hidden className="flex gap-1.5">
            <span className="size-3 rounded-full bg-danger" />
            <span className="size-3 rounded-full bg-warning" />
            <span className="size-3 rounded-full bg-success" />
          </span>
          <span aria-hidden className="flex gap-2 text-text-disabled max-sm:hidden">
            <Chevron className="rotate-180" />
            <Chevron />
          </span>
        </div>
        <AddressBar frame={frame} />
        <span aria-hidden className="flex justify-end text-text-muted max-sm:hidden">
          <PlusIcon />
        </span>
        <LoadLine key={frame.typing} clock={clock} typing={frame.typing} reduced={reduced} />
      </div>
      <div
        id={panelId}
        role="tabpanel"
        aria-labelledby={tabId}
        className="relative aspect-[4/5] overflow-hidden bg-bg sm:aspect-[16/10]"
      >
        <AnimatePresence initial={false}>
          <motion.div
            key={site?.id ?? "start"}
            aria-hidden
            className="absolute inset-0"
            initial="hidden"
            animate="shown"
            exit="gone"
            variants={reduced ? REDUCED_PAGE : PAGE}
          >
            {site ? <ClientArt id={site.id} /> : <StartPage />}
          </motion.div>
        </AnimatePresence>
        {/* What the picture shows, for assistive tech: the drawn site itself is decoration. */}
        <p className="sr-only">{site ? `${site.client}: ${site.title}.` : reel.start.line}</p>
      </div>
    </div>
  );
}

const REDUCED_PAGE = {
  hidden: { opacity: 0 },
  shown: { opacity: 1, transition: reducedFade },
  gone: { opacity: 0, transition: reducedFade },
};

/** The address bar: the domain of the site that is up, or the one being typed with a caret. */
function AddressBar({ frame }: { frame: Frame }) {
  const typing = frame.typing >= 0;
  const domain = typing ? SITES[frame.typing].domain.slice(0, frame.typed) : frame.page >= 0 ? SITES[frame.page].domain : "";
  return (
    <div
      aria-hidden
      className={`flex h-7 min-w-0 items-center justify-center gap-1.5 rounded-control-sm px-3 text-ui-sm transition-colors duration-(--eased-duration-fast) ease-standard ${
        typing ? "bg-surface-active text-text" : "bg-surface-hover text-text-secondary"
      }`}
    >
      {!typing && domain && <LockIcon />}
      <span className="truncate">
        {domain || (!typing && <span className="text-text-muted">{brand.name.toLowerCase()} / work</span>)}
      </span>
      {typing && <span className="-ml-1 h-3.5 w-px shrink-0 bg-text" />}
    </div>
  );
}

/** The 2px load line under the toolbar: it runs while a site loads, then fades. */
function LoadLine({ clock, typing, reduced }: { clock: MotionValue<number>; typing: number; reduced: boolean }) {
  const span = SPANS[Math.max(typing, 0)];
  const scaleX = useTransform(clock, [span.loaded - LOAD, span.loaded], [0, 1], { clamp: true });
  if (reduced || typing < 0) return null;
  return <motion.span aria-hidden className="absolute inset-x-0 -bottom-px h-0.5 origin-left bg-(--tessera-tile)" style={{ scaleX }} />;
}

/** The new tab before the first site: the studio's own start page, quiet. */
function StartPage() {
  return (
    <div className="absolute inset-0 flex flex-col items-center justify-center gap-in bg-surface-sunken text-center">
      <p className="text-ui-md font-medium text-text">{reel.start.title}</p>
      <ol className="flex flex-col gap-1 text-ui text-text-muted">
        {SITES.map((s, i) => (
          <li key={s.id} className="tabular-nums">
            {String(i + 1).padStart(2, "0")} {s.client}
          </li>
        ))}
      </ol>
    </div>
  );
}

function Chevron({ className = "" }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" className={`size-4 shrink-0 ${className}`} fill="none" aria-hidden>
      <path d="m6.5 3.75 4.25 4.25-4.25 4.25" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function PlusIcon() {
  return (
    <svg viewBox="0 0 16 16" className="size-4 shrink-0" fill="none" aria-hidden>
      <path d="M8 3.25v9.5M3.25 8h9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
    </svg>
  );
}

function LockIcon() {
  return (
    <svg viewBox="0 0 16 16" className="size-3.5 shrink-0 text-text-muted" fill="none" aria-hidden>
      <rect x="3.75" y="7" width="8.5" height="6" rx="1.25" stroke="currentColor" strokeWidth="1.5" />
      <path d="M5.75 7V5.5a2.25 2.25 0 0 1 4.5 0V7" stroke="currentColor" strokeWidth="1.5" />
    </svg>
  );
}

function PauseIcon() {
  return (
    <svg viewBox="0 0 16 16" className="size-4 shrink-0" fill="currentColor" aria-hidden>
      <rect x="4" y="3.5" width="2.75" height="9" rx="0.75" />
      <rect x="9.25" y="3.5" width="2.75" height="9" rx="0.75" />
    </svg>
  );
}

/** Shifted right about 10% of its width, so it sits optically centred (rule 57). */
function PlayIcon() {
  return (
    <svg viewBox="0 0 16 16" className="size-4 shrink-0" fill="currentColor" aria-hidden>
      <path d="M5.5 3.6v8.8a.75.75 0 0 0 1.14.64l7-4.4a.75.75 0 0 0 0-1.28l-7-4.4a.75.75 0 0 0-1.14.64Z" />
    </svg>
  );
}

function ReplayIcon() {
  return (
    <svg viewBox="0 0 16 16" className="size-4 shrink-0" fill="none" aria-hidden>
      <path d="M3.25 8a4.75 4.75 0 1 0 1.4-3.37M3.25 3v2.5h2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}
```
