# Throughline Feature

> A feature section for an AI product-photo studio: one product shot in the middle of a stage, and every crop and format it becomes riding out from under it along five dotted spokes (marketplace square, feed, story, banner, alt text, WebP). Copy and three numbers sit beside it. Hold a tile to stop its spoke and read it.

A Feature Section block from KeyframeUI: a whole landing-page section built around the Throughline component, free to install as source with the shadcn CLI. Built with React and Tailwind CSS.

- Live preview: https://keyframeui.com/blocks/feature-sections/throughline-feature
- Category: Feature Sections
- Built with: [Throughline](https://keyframeui.com/components/throughline.md)
- npm dependencies: clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the block. It lands in `components/throughline-feature.tsx`, and the CLI also adds the components it's built with (`components/ui/throughline.tsx`) and `lib/utils.ts`, and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/throughline-feature
```

Or install by URL, without registering the namespace:

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

## Usage

Put it where the section goes. The copy, links and data sit in constants at the top of the file; edit them there.

```tsx
import { ThroughlineFeature } from "@/components/throughline-feature";

export default function Page() {
  return <ThroughlineFeature />;
}
```

## Source

```tsx title="components/throughline-feature.tsx"
"use client";

import * as React from "react";

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

/* ── Content ─────────────────────────────────────────────────
 * Everything you'd change to make the section yours: the
 * palette, the copy, the product shot and the formats. Every
 * line starts under the shot, so each format slides out from
 * behind it and rides off to its station.
 * ─────────────────────────────────────────────────────────── */

const PALETTE = {
  light: { paper: "#ffffff", ink: "#0b0b10", accent: "#5b2bff", stage: "#f4f2fb" },
  dark: { paper: "#07070c", ink: "#f5f5f7", accent: "#9b83ff", stage: "#0f0e16" },
};

const EYEBROW = "Every crop";
const HEADLINE = "One shot in. Every size your store needs out.";
const BODY =
  "Loupe reframes the same photo for each place it's going, keeps the product centered and the shadow where the light says it falls, and writes the alt text while it's there.";
const STATS = [
  { value: "38", label: "crops and formats from one upload" },
  { value: "84 KB", label: "average listing image, WebP" },
  { value: "9", label: "languages of alt text" },
];
const LINK = { label: "See every format", href: "#formats" };

const SHOT = {
  src: "https://images.unsplash.com/photo-1505740420928-5e560c06d30e?auto=format&fit=crop&w=720&h=720&q=80",
  alt: "Over-ear headphones on a yellow backdrop",
  caption: "studio-headphones.jpg",
};

/** An aspect-ratio frame: the format itself, drawn to scale. */
function Frame({ w, h }: { w: number; h: number }) {
  const s = 16 / Math.max(w, h);
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden>
      <rect x={12 - (w * s) / 2} y={12 - (h * s) / 2} width={w * s} height={h * s} rx="1.6" stroke="var(--accent)" strokeWidth="2" />
    </svg>
  );
}

const FORMATS: ThroughlineItem[] = [
  { label: "1:1 · Marketplace listing", line: 0, icon: <Frame w={1} h={1} /> },
  { label: "4:5 · Feed post", line: 1, icon: <Frame w={4} h={5} /> },
  { label: "9:16 · Stories and Reels", line: 2, icon: <Frame w={9} h={16} /> },
  { label: "16:9 · Site banner", line: 3, icon: <Frame w={16} h={9} /> },
  { label: "3:4 · Lookbook page", line: 4, icon: <Frame w={3} h={4} /> },
  {
    label: "Alt text · “Black over-ear headphones…”",
    line: 0,
    icon: (
      <svg viewBox="0 0 24 24" fill="none" aria-hidden>
        <path d="m4.5 18 4.5-12 4.5 12M6.2 14h5.6M16.5 9.5v8.5M16.5 6v.01" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
      </svg>
    ),
  },
  {
    label: "WebP · 84 KB, 2400px",
    line: 2,
    icon: (
      <svg viewBox="0 0 24 24" fill="none" aria-hidden>
        <path d="M7 4.5h7l4 4v11H7Z M14 4.5v4h4" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" />
      </svg>
    ),
  },
  {
    label: "2:3 · Pinterest pin",
    line: 1,
    icon: <Frame w={2} h={3} />,
  },
  {
    label: "Shadow · soft, light from 30° left",
    line: 3,
    icon: (
      <svg viewBox="0 0 24 24" fill="none" aria-hidden>
        <circle cx="12" cy="10" r="4.5" stroke="var(--accent)" strokeWidth="2" />
        <ellipse cx="13.5" cy="19" rx="5.5" ry="1.4" fill="var(--accent)" opacity="0.45" />
      </svg>
    ),
  },
  {
    label: "Cutout · PNG with transparency",
    line: 4,
    icon: (
      <svg viewBox="0 0 24 24" fill="none" aria-hidden>
        <rect x="4.5" y="4.5" width="15" height="15" rx="2" stroke="var(--accent)" strokeWidth="2" strokeDasharray="2.6 2.4" />
      </svg>
    ),
  },
];

/** Five spokes out from under the shot at [50, 50]. */
const LINES: ThroughlineLine[] = [
  { from: [50, 50], to: [8, 6], bend: 0.1, behind: true },
  { from: [50, 50], to: [94, 8], bend: -0.12, behind: true },
  { from: [50, 50], to: [98, 54], bend: 0.08, behind: true },
  { from: [50, 50], to: [90, 96], bend: 0.12, behind: true },
  { from: [50, 50], to: [6, 90], bend: -0.1, behind: true },
];

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

export function ThroughlineFeature() {
  return (
    <section
      style={
        {
          "--paper-light": PALETTE.light.paper,
          "--ink-light": PALETTE.light.ink,
          "--accent-light": PALETTE.light.accent,
          "--stage-light": PALETTE.light.stage,
          "--paper-dark": PALETTE.dark.paper,
          "--ink-dark": PALETTE.dark.ink,
          "--accent-dark": PALETTE.dark.accent,
          "--stage-dark": PALETTE.dark.stage,
        } as React.CSSProperties
      }
      className="relative overflow-hidden bg-(--paper) text-(--ink) [--accent:var(--accent-light)] [--ink:var(--ink-light)] [--paper:var(--paper-light)] [--stage:var(--stage-light)] dark:[--accent:var(--accent-dark)] dark:[--ink:var(--ink-dark)] dark:[--paper:var(--paper-dark)] dark:[--stage:var(--stage-dark)]"
    >
      <div className="mx-auto grid max-w-6xl items-center gap-12 px-6 py-24 sm:py-28 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-16">
        <div>
          <p className="text-sm font-semibold text-(--accent)">{EYEBROW}</p>
          <h2 className="mt-3 text-4xl font-semibold tracking-[-0.04em] text-balance sm:text-5xl">{HEADLINE}</h2>
          <p className="mt-5 max-w-md text-[16px] leading-relaxed text-pretty opacity-70">{BODY}</p>
          <dl className="mt-9 grid max-w-md grid-cols-3 gap-5 border-t border-current/10 pt-6">
            {STATS.map((stat) => (
              <div key={stat.label}>
                <dt className="sr-only">{stat.label}</dt>
                <dd className="text-2xl font-semibold tracking-[-0.03em] tabular-nums">{stat.value}</dd>
                <dd className="mt-1 text-[13px] leading-snug opacity-60">{stat.label}</dd>
              </div>
            ))}
          </dl>
          <a
            href={LINK.href}
            className="group mt-9 inline-flex items-center gap-1.5 rounded-full text-[15px] font-semibold text-(--accent) focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-(--accent)"
          >
            {LINK.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>

        <Throughline lines={LINES} items={FORMATS} duration={18} className="rounded-[32px] bg-(--stage) text-(--accent) [--throughline-tile:clamp(2.5rem,8.5cqi,3.5rem)]">
          <div className="flex h-[clamp(22rem,42vw,30rem)] items-center justify-center">
            <figure className="w-[min(15rem,46%)] rounded-[22px] bg-(--paper) p-2 shadow-[0_30px_60px_-30px_rgb(30_20_80/0.45),0_0_0_1px_rgb(0_0_0/0.04)] dark:shadow-[0_30px_60px_-30px_rgb(0_0_0/0.9),0_0_0_1px_rgb(255_255_255/0.06)]">
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img src={SHOT.src} alt={SHOT.alt} width={720} height={720} className="aspect-square w-full rounded-[16px] object-cover" />
              <figcaption className="flex items-center justify-between px-1.5 pt-2 pb-0.5 text-[11px] text-(--ink)">
                <span className="truncate font-medium">{SHOT.caption}</span>
                <span className="shrink-0 pl-2 whitespace-nowrap opacity-50">38 out</span>
              </figcaption>
            </figure>
          </div>
        </Throughline>
      </div>
    </section>
  );
}
```
