# Tollgate — Animated App UI Block for React

> In-app upgrade / paywall block: TollgateLock dims any feature behind a frosted veil with a Pro chip that lifts on hover, and its Upgrade button morphs into a focus-trapped upgrade dialog — usage meter at its limit, monthly/annual toggle with savings badge, plan radio cards with Tally-rolled prices, a ticking unlock list and async onUpgrade that bursts Sparks into “You're on Pro” and lifts every lock under the provider. Presentation only — gate for real server-side.

- Page: https://crucibleui.com/components/app/tollgate
- Category: [app](https://crucibleui.com/components/app)
- Tier: pro (one-time purchase, see https://crucibleui.com/pricing)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/tollgate
```

Pro install requires adding the token-bearing `@crucible` registry to components.json (issued at https://crucibleui.com/account after purchase).

Import from `@/components/crucible/tollgate`.

## Props (`TollgateProviderProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `unlocked?` | `boolean` | — | Controlled unlocked state. Omit to let a successful upgrade unlock automatically. |
| `defaultUnlocked?` | `boolean` | `false` | Initial unlocked state when uncontrolled. |
| `onUnlockedChange?` | `(unlocked: boolean) => void` | — | Fires when a successful upgrade (or `reset()`) changes the unlocked state. |
| `onUpgrade?` | `TollgateUpgradeHandler` | — | Called with the chosen plan and cycle. Resolve to finish the upgrade — every lock under this provider lifts its veil; reject to show the rejection message (or a generic one) inline in the dialog. |
| `children?` | `React.ReactNode` | — | — |

## Usage example

```tsx
"use client";

import * as React from "react";

import {
  Tollgate,
  TollgateLock,
  TollgateProvider,
  type TollgateHandle,
} from "@/components/crucible/tollgate";

/** Seeded PRNG so the sample charts are identical on server and client. */
function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const TRAFFIC = (() => {
  const rand = mulberry32(7);
  const pts: number[] = [];
  let v = 46;
  for (let i = 0; i < 30; i++) {
    v += (rand() - 0.42) * 9 + Math.sin(i / 3.2) * 2.5;
    v = Math.max(18, Math.min(96, v));
    pts.push(v);
  }
  return pts;
})();

const COHORTS = (() => {
  const rand = mulberry32(19);
  return Array.from({ length: 6 }, (_, r) =>
    Array.from({ length: 8 - r }, (_, c) => (c === 0 ? 100 : Math.round(100 * Math.pow(0.8 + rand() * 0.06, c) - r)))
  );
})();

const KPIS = [
  { label: "Visitors", value: "48,210", delta: "+12.4%", up: true },
  { label: "Sign-ups", value: "1,836", delta: "+6.1%", up: true },
  { label: "Conversion", value: "3.81%", delta: "−0.4%", up: false },
];

function TrafficChart() {
  const w = 300;
  const h = 120;
  const step = w / (TRAFFIC.length - 1);
  const line = TRAFFIC.map((v, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(h - (v / 100) * h).toFixed(1)}`).join(" ");
  return (
    <svg aria-hidden viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="h-full w-full">
      <defs>
        <linearGradient id="tg-demo-fill" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#ffffff" stopOpacity="0.14" />
          <stop offset="100%" stopColor="#ffffff" stopOpacity="0" />
        </linearGradient>
      </defs>
      {[0.25, 0.5, 0.75].map((g) => (
        <line key={g} x1="0" x2={w} y1={h * g} y2={h * g} stroke="#ffffff" strokeOpacity="0.06" vectorEffect="non-scaling-stroke" />
      ))}
      <path d={`${line} L${w},${h} L0,${h} Z`} fill="url(#tg-demo-fill)" />
      <path d={line} fill="none" stroke="#e5e5e5" strokeWidth="1.5" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
    </svg>
  );
}

function CohortGrid() {
  return (
    <div className="grid gap-1 text-[10px] tabular-nums">
      {COHORTS.map((row, r) => (
        <div key={r} className="flex items-center gap-1">
          <span className="w-9 shrink-0 text-white/40">Wk {r + 1}</span>
          {Array.from({ length: 8 }, (_, c) => {
            const v = row[c];
            return (
              <span
                key={c}
                className="grid h-5 flex-1 place-items-center rounded-[4px] text-white/80"
                style={{ background: v === undefined ? "rgba(255,255,255,0.02)" : `rgba(255,255,255,${0.04 + (v / 100) * 0.28})` }}
              >
                {v === undefined ? "" : <span className="hidden @[22rem]:inline">{v}%</span>}
              </span>
            );
          })}
        </div>
      ))}
    </div>
  );
}

export default function TollgateDemo() {
  const ref = React.useRef<TollgateHandle>(null);

  return (
    <div className="relative isolate flex h-full min-h-[600px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950 p-3 sm:p-6">
      <TollgateProvider onUpgrade={() => new Promise<void>((resolve) => window.setTimeout(resolve, 1300))}>
        <div className="relative z-10 flex w-full max-w-4xl flex-col gap-3">
          <p className="text-center text-xs text-white/40">Click Upgrade.</p>

          <div className="@container rounded-2xl border border-white/[0.08] bg-neutral-900/60 p-4 sm:p-5">
            <div className="flex items-center justify-between gap-3">
              <div>
                <p className="text-sm font-semibold text-white">Analytics</p>
                <p className="text-xs text-white/40">Fieldline workspace</p>
              </div>
              <span className="rounded-lg border border-white/[0.08] bg-white/[0.03] px-2.5 py-1 text-xs text-white/60">
                Last 30 days
              </span>
            </div>

            <div className="mt-4 grid grid-cols-3 gap-2 @lg:gap-3">
              {KPIS.map((k) => (
                <div key={k.label} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-2.5 @lg:p-3.5">
                  <p className="truncate text-[11px] text-white/45">{k.label}</p>
                  <p className="mt-1 text-sm font-semibold tabular-nums tracking-tight text-white @lg:text-xl">{k.value}</p>
                  <p className={`mt-0.5 text-[11px] tabular-nums ${k.up ? "text-emerald-300/80" : "text-white/40"}`}>{k.delta}</p>
                </div>
              ))}
            </div>

            <div className="mt-3 grid gap-3 @2xl:grid-cols-2">
              <div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3.5">
                <div className="flex items-baseline justify-between">
                  <p className="text-xs font-medium text-white/70">Traffic</p>
                  <p className="text-[11px] tabular-nums text-white/40">Daily visitors</p>
                </div>
                <div className="mt-3 h-[150px]">
                  <TrafficChart />
                </div>
              </div>

              <TollgateLock
                feature="Retention cohorts"
                description="See how each week's sign-ups stick around over time."
                className="border border-white/[0.06] bg-white/[0.02]"
              >
                <div className="p-3.5">
                  <div className="flex items-baseline justify-between">
                    <p className="text-xs font-medium text-white/70">Retention cohorts</p>
                    <p className="text-[11px] tabular-nums text-white/40">Weekly</p>
                  </div>
                  <div className="mt-3 min-h-[150px]">
                    <CohortGrid />
                  </div>
                </div>
              </TollgateLock>
            </div>
          </div>

          <div className="flex justify-center">
            <button
              type="button"
              onClick={() => ref.current?.reset()}
              className="rounded text-xs text-white/35 underline-offset-4 transition-colors hover:text-white hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
            >
              Reset demo
            </button>
          </div>
        </div>

        <Tollgate ref={ref} usage={{ label: "cohort reports", used: 3, limit: 3, currentPlan: "Free" }} />
      </TollgateProvider>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.
