# Echo Reel Pricing

> A pricing section where the plan picker is one giant word. Click the plan printed above or below, or press ↑ ↓, and the reel turns it into the middle and inks it in, while the card beside it rolls the price and the monthly photo count with an Odometer. A monthly or yearly switch rolls the price too.

A Pricing Section block from KeyframeUI: a whole landing-page section built around the Echo Reel and Odometer components, free to install as source with the shadcn CLI. Built with React, Tailwind CSS and Motion.

- Live preview: https://keyframeui.com/blocks/pricing-sections/echo-reel-pricing
- Category: Pricing Sections
- Built with: [Echo Reel](https://keyframeui.com/components/echo-reel.md), [Odometer](https://keyframeui.com/components/odometer.md)
- npm dependencies: clsx, tailwind-merge, motion

## 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/echo-reel-pricing.tsx`, and the CLI also adds the components it's built with (`components/ui/echo-reel.tsx`, `components/ui/odometer.tsx`) and `lib/utils.ts`, and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/echo-reel-pricing
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/echo-reel-pricing.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 { EchoReelPricing } from "@/components/echo-reel-pricing";

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

## Source

```tsx title="components/echo-reel-pricing.tsx"
"use client";

import * as React from "react";

import { EchoReel } from "@/components/ui/echo-reel";
import { Odometer } from "@/components/ui/odometer";

/* ── Content ─────────────────────────────────────────────────
 * Everything you'd change to make the section yours: the
 * palette, the plans and the copy. The giant word is the plan
 * picker: click the word above or below, or focus it and use
 * the arrow keys, and the card beside it rolls to that plan.
 * ─────────────────────────────────────────────────────────── */

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

const EYEBROW = "Pricing";
const HEADLINE = "Pay for photos, not seats.";
const HINT = "Pick a plan on the reel, or focus it and press ↑ ↓";
const YEARLY_SAVING = 0.2;

const PLANS = [
  {
    name: "Starter",
    monthly: 19,
    photos: 60,
    blurb: "For a shop with a few new products a month.",
    features: ["Cutouts and soft shadows", "Every marketplace crop", "Alt text in English", "Exports up to 2K", "Email support"],
    cta: { label: "Start with Starter", href: "#starter" },
  },
  {
    name: "Studio",
    monthly: 79,
    photos: 400,
    blurb: "For brands shooting a full catalog every season.",
    features: ["Everything in Starter", "Lifestyle scenes, lit to match", "12 colorways from one shot", "Alt text in 9 languages", "4K exports for print"],
    cta: { label: "Try Studio free for 14 days", href: "#studio" },
  },
  {
    name: "Brand",
    monthly: 249,
    photos: 2000,
    blurb: "For teams feeding a store, ads and wholesale at once.",
    features: ["Everything in Studio", "Your own trained look", "Bulk uploads from a folder", "Five seats, review links", "A shoot lead on Slack"],
    cta: { label: "Talk to us about Brand", href: "#brand" },
  },
];

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

const usd: Intl.NumberFormatOptions = { style: "currency", currency: "USD", maximumFractionDigits: 0 };

export function EchoReelPricing() {
  const [plan, setPlan] = React.useState(1);
  const [yearly, setYearly] = React.useState(true);
  const current = PLANS[plan];
  const price = Math.round(current.monthly * (yearly ? 1 - YEARLY_SAVING : 1));

  return (
    <section
      style={
        {
          "--paper-light": PALETTE.light.paper,
          "--ink-light": PALETTE.light.ink,
          "--accent-light": PALETTE.light.accent,
          "--card-light": PALETTE.light.card,
          "--paper-dark": PALETTE.dark.paper,
          "--ink-dark": PALETTE.dark.ink,
          "--accent-dark": PALETTE.dark.accent,
          "--card-dark": PALETTE.dark.card,
        } as React.CSSProperties
      }
      className="relative overflow-hidden bg-(--paper) text-(--ink) [--accent:var(--accent-light)] [--card:var(--card-light)] [--ink:var(--ink-light)] [--paper:var(--paper-light)] dark:[--accent:var(--accent-dark)] dark:[--card:var(--card-dark)] dark:[--ink:var(--ink-dark)] dark:[--paper:var(--paper-dark)]"
    >
      <div className="mx-auto max-w-6xl px-6 py-24 sm:py-28">
        <div className="flex flex-col items-start justify-between gap-6 sm:flex-row sm:items-end">
          <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>
          </div>
          <div role="group" aria-label="Billing" className="flex rounded-full bg-(--card) p-1 text-sm font-medium">
            {[false, true].map((y) => (
              <button
                key={String(y)}
                type="button"
                aria-pressed={yearly === y}
                onClick={() => setYearly(y)}
                className="flex h-9 items-center gap-1.5 rounded-full px-4 transition-[background-color,color,box-shadow] duration-200 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) aria-pressed:bg-(--paper) aria-pressed:shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_rgb(0_0_0/0.04)] [&:not([aria-pressed=true])]:opacity-60"
              >
                {y ? "Yearly" : "Monthly"}
                {y && <span className="rounded-full bg-(--accent) px-1.5 py-0.5 text-[10px] leading-none font-semibold text-white dark:text-[#07070c]">−20%</span>}
              </button>
            ))}
          </div>
        </div>

        <div className="mt-12 grid items-center gap-10 lg:mt-16 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] lg:gap-14">
          <div>
            <EchoReel
              words={PLANS.map((p) => p.name)}
              value={plan}
              onValueChange={(index) => setPlan(index)}
              interval={false}
              className="text-(--accent)"
            />
            <p className="mt-4 text-center text-[13px] opacity-55">{HINT}</p>
          </div>

          <div className="rounded-[28px] bg-(--card) p-7 sm:p-8">
            <div className="flex items-baseline justify-between gap-4">
              <p className="text-sm font-semibold">{current.name}</p>
              <p className="text-sm opacity-60">
                <Odometer value={current.photos} className="font-semibold tabular-nums opacity-100" /> photos a month
              </p>
            </div>
            <p className="mt-5 flex items-baseline gap-1.5">
              <Odometer value={price} format={usd} className="text-5xl font-semibold tracking-[-0.04em]" />
              <span className="text-sm opacity-60">/month</span>
            </p>
            <p className="mt-1.5 text-[13px] opacity-60">
              {yearly ? `Billed $${(price * 12).toLocaleString("en-US")} a year` : "Billed monthly, cancel anytime"}
            </p>
            <p className="mt-5 text-[15px] leading-relaxed opacity-80">{current.blurb}</p>
            <ul className="mt-5 space-y-2.5 text-[14px]">
              {current.features.map((feature) => (
                <li key={feature} className="flex gap-2.5">
                  <svg viewBox="0 0 16 16" aria-hidden className="mt-0.5 size-4 shrink-0 text-(--accent)">
                    <path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
                  </svg>
                  {feature}
                </li>
              ))}
            </ul>
            <a
              href={current.cta.href}
              className="mt-7 flex h-12 items-center justify-center rounded-full bg-(--accent) px-6 text-[15px] font-semibold text-white transition-[opacity,scale] duration-150 hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) active:scale-[0.98] dark:text-[#07070c]"
            >
              {current.cta.label}
            </a>
          </div>
        </div>
      </div>
    </section>
  );
}
```
