# Throughline Hero

> A hero for an AI product-photo studio, built on one giant word. An Echo Reel turns through what it shoots (sneakers, skincare, ceramics), each word inking in as it rolls into the middle, while Throughline tiles of what it hands back ride dotted lines across the reel and pick up the violet through their glass. Hold a tile to stop its line and read it; a button in the corner stops all the motion.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/throughline-hero).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/throughline-hero
- Screenshot: https://shadcn.exchange/shot/keyframeui/throughline-hero/light.png
- Registry item: https://keyframeui.com/r/throughline-hero.json
- Categories: blocks, hero-sections
- Listed: 2026-11-02, source last changed 2026-11-02

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/keyframeui/throughline-hero.json
```

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

## Dependencies

- Registry items: `https://keyframeui.com/r/echo-reel.json`, `https://keyframeui.com/r/throughline.json`

## Notes from the registry

Live preview and the components it's built with: https://keyframeui.com/blocks/hero-sections/throughline-hero

## Files

### `registry/blocks/throughline-hero.tsx`

```tsx
"use client";

import * as React from "react";

import { EchoReel } from "@/components/ui/echo-reel";
import { Throughline, type ThroughlineItem, type ThroughlineLine } from "@/components/ui/throughline";

/* ── Content ─────────────────────────────────────────────────
 * Everything you'd change to make the hero yours: the palette,
 * the nav, the words on the reel, the tiles and the copy. The
 * reel turns through what Loupe shoots; the tiles riding
 * through it are what it hands back. The button in the corner
 * stops both for visitors who want the page still.
 * ─────────────────────────────────────────────────────────── */

/** Crisp white or deep night, one electric violet, and a few loud tile colors. */
const PALETTE = {
  light: { paper: "#ffffff", ink: "#0b0b10", accent: "#5b2bff" },
  dark: { paper: "#07070c", ink: "#f5f5f7", accent: "#9b83ff" },
};

const BRAND = { name: "Loupe", href: "#" };
const NAV_LINKS = [
  { label: "Product", href: "#product" },
  { label: "Examples", href: "#examples" },
  { label: "Pricing", href: "#pricing" },
  { label: "Changelog", href: "#changelog" },
];
const SIGN_IN = { label: "Sign in", href: "#sign-in" };
const NAV_CTA = { label: "Try it free", href: "#start" };

/** Read as one sentence by screen readers; shown as a line over the reel. */
const LEAD = "Studio product photos for";
const WORDS = ["Sneakers", "Skincare", "Ceramics", "Candles", "Jewelry"];
const SUBLINE =
  "Upload one phone shot. Loupe cuts it out, lights it and hands back every size your shop needs, in under a minute.";
const PRIMARY_CTA = { label: "Shoot 20 photos free", href: "#start" };
const SECONDARY_CTA = { label: "See before and after", href: "#examples" };

/** What Loupe hands back. Each rides one of the lines; hold one to read it. */
const TILES: { label: string; line: number; bg: string; glyph: React.ReactNode }[] = [
  {
    label: "Cutout · background gone in 2s",
    line: 0,
    bg: "#c6ff3d",
    glyph: (
      <>
        <rect x="4.5" y="4.5" width="15" height="15" rx="2" stroke="#0b0b10" strokeWidth="1.8" strokeDasharray="2.6 2.4" />
        <circle cx="12" cy="12" r="3.6" fill="#0b0b10" />
      </>
    ),
  },
  {
    label: "Shadow · soft, from the light you pick",
    line: 1,
    bg: "#0b0b10",
    glyph: (
      <>
        <circle cx="12" cy="9.5" r="4.5" fill="#fff" />
        <ellipse cx="12" cy="18.5" rx="6" ry="1.6" fill="#fff" opacity="0.45" />
      </>
    ),
  },
  {
    label: "Variants · 12 colorways from one shot",
    line: 2,
    bg: "linear-gradient(135deg,#ff4fa3,#ff7a1a)",
    glyph: (
      <>
        <circle cx="8" cy="12" r="2.6" fill="#fff" />
        <circle cx="12" cy="12" r="2.6" fill="#fff" opacity="0.75" />
        <circle cx="16" cy="12" r="2.6" fill="#fff" opacity="0.5" />
      </>
    ),
  },
  {
    label: "Upscale · 4K, sharp enough for print",
    line: 0,
    bg: "#ffffff",
    glyph: <path d="M5 8V5h3M16 5h3v3M19 16v3h-3M8 19H5v-3M9 9h6v6H9Z" stroke="#5b2bff" strokeWidth="2" />,
  },
  {
    label: "Alt text · written in 9 languages",
    line: 1,
    bg: "#ff5a1f",
    glyph: <path d="m5 18 4.5-12L14 18M6.7 14h5.6M15.5 18v-5.2M15.5 12.8c0-1.6 1-2.3 2.2-2.3s2.3.7 2.3 2.3V18" stroke="#fff" strokeWidth="2" />,
  },
  {
    label: "Sizes · every marketplace crop",
    line: 2,
    bg: "#5b2bff",
    glyph: <path d="M7 3.5v13.5h13.5M3.5 7H17v13.5" stroke="#fff" strokeWidth="2" />,
  },
  {
    label: "Scene · a lifestyle backdrop, lit to match",
    line: 0,
    bg: "#1d9bff",
    glyph: (
      <>
        <circle cx="16.5" cy="7.5" r="2" fill="#fff" />
        <path d="m3.5 18 5-6.5 4 4.5 2.5-2.5 5.5 4.5" stroke="#fff" strokeWidth="2" />
      </>
    ),
  },
  {
    label: "Retouch · dust, glare and creases",
    line: 1,
    bg: "#0b0b10",
    glyph: <path d="M12 3.5 13.6 10 20 12l-6.4 2L12 20.5 10.4 14 4 12l6.4-2ZM18.5 3.5v3M17 5h3" stroke="#c6ff3d" strokeWidth="1.8" />,
  },
];

/** Two lines across the reel and one behind it, in % of the reel's box. */
const LINES: ThroughlineLine[] = [
  { from: [4, 14], to: [96, 78], bend: 0.1 },
  { from: [3, 88], to: [97, 10], bend: -0.08 },
  { from: [1, 50], to: [99, 42], bend: 0.2, behind: true },
];

/* ── Section ─────────────────────────────────────────────── */

const ITEMS: ThroughlineItem[] = TILES.map((tile) => ({
  label: tile.label,
  line: tile.line,
  icon: (
    <span className="flex items-center justify-center rounded-[26%] shadow-[inset_0_1px_0_rgb(255_255_255/0.25)]" style={{ background: tile.bg }}>
      <svg viewBox="0 0 24 24" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden className="size-[64%]">
        {tile.glyph}
      </svg>
    </span>
  ),
}));

export function ThroughlineHero() {
  const [still, setStill] = React.useState(false);

  return (
    <section
      style={
        {
          "--paper-light": PALETTE.light.paper,
          "--ink-light": PALETTE.light.ink,
          "--accent-light": PALETTE.light.accent,
          "--paper-dark": PALETTE.dark.paper,
          "--ink-dark": PALETTE.dark.ink,
          "--accent-dark": PALETTE.dark.accent,
        } as React.CSSProperties
      }
      className="relative overflow-hidden bg-(--paper) text-(--ink) [--accent:var(--accent-light)] [--ink:var(--ink-light)] [--paper:var(--paper-light)] dark:[--accent:var(--accent-dark)] dark:[--ink:var(--ink-dark)] dark:[--paper:var(--paper-dark)]"
    >
      <header className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-6 px-6">
        <a
          href={BRAND.href}
          className="flex items-center gap-2 rounded-md text-[17px] font-semibold tracking-[-0.03em] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-(--accent)"
        >
          <LoupeMark className="size-6" />
          {BRAND.name}
        </a>
        <nav aria-label="Main" className="hidden items-center gap-1 md:flex">
          {NAV_LINKS.map((link) => (
            <a
              key={link.label}
              href={link.href}
              className="rounded-full px-3.5 py-2 text-[14px] font-medium opacity-70 transition-opacity duration-150 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-(--accent)"
            >
              {link.label}
            </a>
          ))}
        </nav>
        <div className="flex items-center gap-1.5">
          <a
            href={SIGN_IN.href}
            className="hidden rounded-full px-3.5 py-2 text-[14px] font-medium opacity-70 transition-opacity duration-150 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-(--accent) sm:block"
          >
            {SIGN_IN.label}
          </a>
          <a
            href={NAV_CTA.href}
            className="rounded-full bg-(--ink) px-4 py-2 text-[14px] font-semibold text-(--paper) transition-[opacity,scale] duration-150 hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) active:scale-[0.97]"
          >
            {NAV_CTA.label}
          </a>
        </div>
      </header>

      <div className="relative mx-auto max-w-7xl px-2 pt-4 pb-14 text-center sm:px-6 sm:pt-6 sm:pb-16">
        <h1 className="sr-only">
          {LEAD} {WORDS.slice(0, -1).join(", ").toLowerCase()} and {WORDS[WORDS.length - 1].toLowerCase()}
        </h1>
        <p aria-hidden className="text-[15px] font-semibold tracking-[-0.02em] sm:text-lg">
          {LEAD}
        </p>

        <Throughline lines={LINES} items={ITEMS} paused={still} className="text-(--accent)">
          <div className="px-2 pt-[max(1.75rem,3.5cqi)] pb-[max(1.5rem,2.5cqi)]">
            <EchoReel
              words={WORDS}
              interval={still ? false : 2.8}
              className="mx-auto w-[min(100%,calc((100svh-25rem)*1.7),56rem)] text-(--accent)"
            />
          </div>
        </Throughline>

        <p className="mx-auto max-w-[31rem] px-4 text-[15px] leading-relaxed text-pretty opacity-70 sm:text-base">{SUBLINE}</p>
        <div className="mt-7 flex flex-wrap items-center justify-center gap-x-5 gap-y-3 px-4">
          <a
            href={PRIMARY_CTA.href}
            className="inline-flex h-12 items-center rounded-full bg-(--accent) px-6 text-[15px] font-semibold text-white shadow-[0_10px_24px_-10px_var(--accent)] transition-[opacity,scale] duration-150 hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) active:scale-[0.97] dark:text-[#07070c]"
          >
            {PRIMARY_CTA.label}
          </a>
          <a
            href={SECONDARY_CTA.href}
            className="group inline-flex h-12 items-center gap-1.5 rounded-full px-2 text-[15px] font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent)"
          >
            {SECONDARY_CTA.label}
            <svg viewBox="0 0 16 16" fill="none" aria-hidden className="size-3.5 transition-transform duration-150 ease-out group-hover:translate-x-0.5">
              <path d="M3 8h10m-4-4 4 4-4 4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
          </a>
        </div>

        <button
          type="button"
          aria-pressed={still}
          onClick={() => setStill((s) => !s)}
          className="absolute right-4 bottom-4 flex size-9 items-center justify-center rounded-full opacity-50 ring-1 ring-current/20 transition-opacity duration-150 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) sm:right-6 sm:bottom-6"
        >
          <span className="sr-only">{still ? "Play the animation" : "Pause the animation"}</span>
          <svg viewBox="0 0 16 16" aria-hidden className="size-3.5" fill="currentColor">
            {still ? <path d="M5 3.5v9l7.5-4.5Z" /> : <path d="M4.5 3.5h2.5v9H4.5ZM9 3.5h2.5v9H9Z" />}
          </svg>
        </button>
      </div>
    </section>
  );
}

/* ── Mark ────────────────────────────────────────────────── */

/** A loupe: a lens ring with the accent catching the light. */
function LoupeMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden className={className}>
      <circle cx="10.5" cy="10.5" r="6.5" stroke="currentColor" strokeWidth="2.6" />
      <path d="M15.5 15.5 20 20" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" />
      <circle cx="10.5" cy="10.5" r="2.6" fill="var(--accent)" />
    </svg>
  );
}
```
