Skip to main content

ns-ui

Pricing Scale

Pricing section rendered as a literal balance scale: two tier cards hang as pans from a canvas beam, billing toggle and feature checkmarks add weight, and beam-torque spring physics settles with one inertial overshoot to show which tier objectively outweighs the other.

Usage

registry/core/pricing-scale/demo.tsx
"use client";

import { CounterpoiseTiers } from "./component";

export default function CounterpoiseTiersDemo() {
  return (
    <main className="flex min-h-screen items-center justify-center bg-background px-6 py-16">
      <div className="w-full max-w-4xl">
        <p className="mb-10 text-center font-mono text-xs tracking-widest text-ns-muted">
          ns-ui / pricing-scale
        </p>
        <div className="mx-auto mb-10 max-w-xl text-center">
          <h1 className="text-4xl font-semibold tracking-tight text-foreground">
            Pricing that weighs itself
          </h1>
          <p className="mt-3 text-sm leading-relaxed text-ns-muted">
            Two plans on a real balance beam. Flip the billing period, tick
            features on and off, and watch which side the physics comes down
            on.
          </p>
        </div>
        <CounterpoiseTiers />
        <p className="mt-10 text-center font-mono text-[11px] text-ns-muted">
          Prices in USD, taxes not included. The beam has no sales quota.
        </p>
      </div>
    </main>
  );
}

Component

registry/core/pricing-scale/component.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// CounterpoiseTiers — two pricing tiers rendered as the pans of a literal
// balance scale. A Canvas 2D layer draws the fulcrum, beam, and chains as 2px
// strokes; the pans are real DOM pricing cards hung from the beam ends and
// positioned every frame via offset transforms from the container center.
// Billing toggle and feature checkboxes add weight (weight = price/10 +
// enabled feature count); the beam angle integrates a torque spring
// (θ'' = -42(θ - θ_target) - 5.5θ') for exactly one visible overshoot, with a
// hard 2s forced-settle deadline so stacked rapid toggles always resolve.
// Idle default look is a ±0.4° ambient sway (~6s period, jittered per mount
// plus a slow secondary harmonic so it never reads as a metronome), suppressed
// for 4s after any interaction. A mono verdict caption under the fulcrum updates
// on settle, never mid-swing. Direct-DOM rAF hot path, refs only.
// ---------------------------------------------------------------------------

export type CounterpoiseFeature = { label: string; included: boolean };

export type CounterpoiseTier = {
  name: string;
  tagline: string;
  /** USD per month at monthly billing */
  monthlyPrice: number;
  cta: string;
  /** primary tier gets the --ns-accent CTA; accent appears nowhere else */
  primary?: boolean;
  features: CounterpoiseFeature[];
};

const STARTER: CounterpoiseTier = {
  name: "Starter",
  tagline: "For weekend projects",
  monthlyPrice: 19,
  cta: "Start free",
  features: [
    { label: "3 projects", included: true },
    { label: "Community support", included: true },
    { label: "1 GB storage", included: true },
    { label: "Custom domain", included: false },
    { label: "API access", included: false },
    { label: "Usage analytics", included: false },
  ],
};

const PRO: CounterpoiseTier = {
  name: "Pro",
  tagline: "For teams with deadlines",
  monthlyPrice: 49,
  cta: "Go Pro",
  primary: true,
  features: [
    { label: "Unlimited projects", included: true },
    { label: "Priority support", included: true },
    { label: "100 GB storage", included: true },
    { label: "Custom domains", included: true },
    { label: "API access", included: true },
    { label: "SSO + audit log", included: false },
  ],
};

// -- physics + geometry constants -------------------------------------------
const STIFF = 42; // s^-2 — spring stiffness
const DAMP = 5.5; // s^-1 — one visible overshoot, settle < 1.4s
const DEADLINE_MS = 2000; // forced-settle snap
const MAX_TILT = (9 * Math.PI) / 180; // tilt clamp ±9°
const TILT_PER_UNIT = 0.028; // rad per weight unit of imbalance
const AMB_AMP = (0.4 * Math.PI) / 180; // idle sway ±0.4°
const AMB_PERIOD = 6000; // base sway period (jittered per mount, see engine)
const SUPPRESS_MS = 4000; // sway suppressed after any interaction
const OMEGA_EPS = 0.001; // rad/s sleep threshold
const THETA_EPS = 0.0008; // rad — "at target" threshold
const PIVOT_Y = 46; // beam pivot y inside the stage
const CHAIN = 46; // chain length from beam end to card top
const LAG_GAIN = 0.3; // pan swing lag vs angular velocity (level at rest)
const LAG_MAX = 0.09; // rad — pan lag clamp during hard swings

type Model = { wL: number; wR: number; verdict: string };
type Engine = { retarget: () => void; setBias: (bias: number) => void };

function clamp(v: number, lo: number, hi: number): number {
  return v < lo ? lo : v > hi ? hi : v;
}

export function CounterpoiseTiers({
  left = STARTER,
  right = PRO,
  annualMultiplier = 0.8,
  className = "",
}: {
  /** tier hung from the left pan */
  left?: CounterpoiseTier;
  /** tier hung from the right pan */
  right?: CounterpoiseTier;
  /** annual billing price multiplier (0.8 = 20% off) */
  annualMultiplier?: number;
  /** extra classes merged onto the rendered root element */
  className?: string;
}) {
  const [billing, setBilling] = useState<"monthly" | "annual">("monthly");
  const [checkedL, setCheckedL] = useState<boolean[]>(() =>
    left.features.map((f) => f.included)
  );
  const [checkedR, setCheckedR] = useState<boolean[]>(() =>
    right.features.map((f) => f.included)
  );

  const stageRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const wrapLRef = useRef<HTMLDivElement>(null);
  const wrapRRef = useRef<HTMLDivElement>(null);
  const cardLRef = useRef<HTMLDivElement>(null);
  const cardRRef = useRef<HTMLDivElement>(null);
  const captionRef = useRef<HTMLParagraphElement>(null);
  const engineRef = useRef<Engine | null>(null);
  const modelRef = useRef<Model>({ wL: 0, wR: 0, verdict: "" });

  const priceOf = (t: CounterpoiseTier) =>
    billing === "annual"
      ? Math.round(t.monthlyPrice * annualMultiplier)
      : t.monthlyPrice;
  const pL = priceOf(left);
  const pR = priceOf(right);

  // weight = monthlyPrice/10 + enabled feature count — the beam's whole truth
  const model = useMemo<Model>(() => {
    const cL = checkedL.filter(Boolean).length;
    const cR = checkedR.filter(Boolean).length;
    const wL = pL / 10 + cL;
    const wR = pR / 10 + cR;
    const d = wL - wR;
    let verdict: string;
    if (Math.abs(d) < 0.05) {
      verdict = `${left.name} and ${right.name} are perfectly balanced`;
    } else {
      const heavy = d > 0 ? left : right;
      const light = d > 0 ? right : left;
      const featDiff = d > 0 ? cL - cR : cR - cL;
      const priceDiff = d > 0 ? pL - pR : pR - pL;
      verdict =
        featDiff > 0
          ? `${heavy.name} outweighs ${light.name} by ${featDiff} feature${featDiff === 1 ? "" : "s"}`
          : `${heavy.name} outweighs ${light.name} by $${priceDiff}/mo of heft`;
    }
    return { wL, wR, verdict };
  }, [checkedL, checkedR, pL, pR, left, right]);
  modelRef.current = model;

  // any weight change re-targets the beam through the same spring
  useEffect(() => {
    engineRef.current?.retarget();
  }, [model]);

  // -- engine: canvas + spring + ambient sway, all outside React -------------
  useEffect(() => {
    const stage = stageRef.current;
    const canvas = canvasRef.current;
    const wrapL = wrapLRef.current;
    const wrapR = wrapRRef.current;
    const cardL = cardLRef.current;
    const cardR = cardRRef.current;
    const caption = captionRef.current;
    if (!stage || !canvas || !wrapL || !wrapR || !cardL || !cardR || !caption)
      return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    // -- inks parsed from tokens at mount, re-derived live on theme flip -----
    let inkFg = "#ededed";
    let inkBorder = "#2e2e2e";
    const derive = () => {
      const cs = getComputedStyle(document.documentElement);
      const pick = (name: string, fb: string) => {
        const v = cs.getPropertyValue(name).trim();
        return v || fb;
      };
      inkFg = pick("--foreground", "#ededed");
      inkBorder = pick("--border", "#2e2e2e");
    };
    derive();

    // -- geometry (recomputed on resize, guarded against zero size) ----------
    let w = 0;
    let h = 0;
    let dpr = 1;
    let cx = 0;
    let armL = 0; // beam half-span
    let cardW = 0;
    let groundY = 0;
    let sized = false;

    const resize = () => {
      const rect = stage.getBoundingClientRect();
      if (rect.width < 40) {
        sized = false; // zero-size guard — loop no-ops until real
        return;
      }
      const nw = rect.width;
      const nCardW = Math.round(clamp((nw - 90) / 2, 150, 288));
      // conditional writes only — keeps the ResizeObserver from looping
      if (wrapL.style.width !== `${nCardW}px`) {
        wrapL.style.width = `${nCardW}px`;
        wrapR.style.width = `${nCardW}px`;
      }
      const cardH = Math.max(cardL.offsetHeight, cardR.offsetHeight);
      const nArm = Math.min((nw - nCardW) / 2 - 6, nCardW / 2 + 40);
      const maxDrop = nArm * Math.sin(MAX_TILT);
      const nGroundY = Math.round(PIVOT_Y + maxDrop + CHAIN + cardH + 14);
      const nh = nGroundY + 40;
      w = nw;
      h = nh;
      cardW = nCardW;
      armL = nArm;
      groundY = nGroundY;
      cx = w / 2;
      dpr = Math.min(2, window.devicePixelRatio || 1);
      const heightPx = `${h}px`;
      if (stage.style.height !== heightPx) stage.style.height = heightPx;
      caption.style.top = `${groundY + 12}px`;
      // canvas is a replaced element: explicit style w/h + dpr backing store
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      canvas.style.width = `${w}px`;
      canvas.style.height = `${h}px`;
      wrapL.style.transformOrigin = `50% ${-CHAIN}px`;
      wrapR.style.transformOrigin = `50% ${-CHAIN}px`;
      sized = true;
    };
    resize();

    // -- full clear + restroke every tick: no accumulation compositing -------
    const render = (thVis: number) => {
      if (!sized) return;
      const cos = Math.cos(thVis);
      const sin = Math.sin(thVis);
      // pans counter-rotate against the beam to hang level; angular velocity
      // adds a clamped pendulum lag so the swing reads as inertia
      const rotDeg =
        (-clamp(om * LAG_GAIN, -LAG_MAX, LAG_MAX) * 180) / Math.PI;

      // pan cards: OFFSET transforms from the container center, never
      // absolute canvas coordinates
      const ends: [number, number][] = [];
      const tops: [number, number][] = [];
      for (const s of [-1, 1] as const) {
        const ex = cx + s * armL * cos;
        const ey = PIVOT_Y + s * armL * sin;
        const x = ex - cardW / 2;
        const y = ey + CHAIN;
        ends.push([ex, ey]);
        tops.push([x, y]);
        const wrap = s === -1 ? wrapL : wrapR;
        wrap.style.transform = `translate3d(${x}px, ${y}px, 0) rotate(${rotDeg}deg)`;
        if (wrap.style.opacity !== "1") wrap.style.opacity = "1";
      }

      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);
      ctx.lineWidth = 2;
      ctx.lineCap = "round";

      // fulcrum column + base + hatch — --border ink
      ctx.strokeStyle = inkBorder;
      ctx.beginPath();
      ctx.moveTo(cx - 5, PIVOT_Y + 8);
      ctx.lineTo(cx - 15, groundY);
      ctx.moveTo(cx + 5, PIVOT_Y + 8);
      ctx.lineTo(cx + 15, groundY);
      ctx.moveTo(cx - 44, groundY);
      ctx.lineTo(cx + 44, groundY);
      for (let i = 0; i < 7; i++) {
        const hx = cx - 38 + i * 12;
        ctx.moveTo(hx + 8, groundY + 1);
        ctx.lineTo(hx, groundY + 9);
      }
      // chains: beam end down to the pan card's top corners
      for (const s of [0, 1] as const) {
        const [ex, ey] = ends[s]!;
        const [tx, ty] = tops[s]!;
        ctx.moveTo(ex, ey + 3);
        ctx.lineTo(tx + 14, ty + 2);
        ctx.moveTo(ex, ey + 3);
        ctx.lineTo(tx + cardW - 14, ty + 2);
      }
      ctx.stroke();

      // beam + pivot + end hooks — --foreground ink
      ctx.strokeStyle = inkFg;
      ctx.beginPath();
      ctx.moveTo(ends[0]![0], ends[0]![1]);
      ctx.lineTo(ends[1]![0], ends[1]![1]);
      ctx.stroke();
      ctx.beginPath();
      ctx.arc(cx, PIVOT_Y, 4.5, 0, Math.PI * 2);
      ctx.stroke();
      for (const [ex, ey] of ends) {
        ctx.beginPath();
        ctx.arc(ex, ey + 1, 2.5, 0, Math.PI * 2);
        ctx.stroke();
      }
    };

    // -- spring state: locals only, never React state ------------------------
    let th = 0;
    let om = 0;
    let bias = 0; // hover preview tilt bias, ±0.3°
    let target = 0;
    let raf = 0;
    let last = 0;
    let onscreen = true;
    let pendingSettle = true;
    let interactionAt = performance.now();
    let deadlineAt = interactionAt + DEADLINE_MS;
    let ramp = 0; // ambient sway ramp 0..1
    let wakeTimer = 0;
    // ambient sway jitter — randomized once per mount so the idle sway never
    // reads as a metronomic, perfectly-repeating cycle
    const ambPeriod = AMB_PERIOD * (0.9 + Math.random() * 0.2); // 5400-6600ms
    const ambPhase = Math.random() * Math.PI * 2;
    const amb2Period = ambPeriod * 2.37; // slow secondary harmonic, non-integer ratio
    const amb2Phase = Math.random() * Math.PI * 2;

    const computeTarget = () => {
      const m = modelRef.current;
      return clamp(
        (m.wR - m.wL) * TILT_PER_UNIT + bias,
        -MAX_TILT,
        MAX_TILT
      );
    };

    const setCaption = () => {
      caption.textContent = modelRef.current.verdict;
    };

    const loop = (now: number) => {
      raf = 0;
      if (!onscreen || document.hidden || !sized) {
        last = 0;
        return; // paused offscreen/hidden — observers wake us
      }
      const dt = last === 0 ? 1 / 60 : Math.min(0.033, (now - last) / 1000);
      last = now;
      const suppressed = now - interactionAt < SUPPRESS_MS;

      // beam torque spring, semi-implicit Euler
      om += (-STIFF * (th - target) - DAMP * om) * dt;
      th += om * dt;
      // forced-settle deadline: stacked rapid toggles always resolve
      if (pendingSettle && now >= deadlineAt) {
        th = target;
        om = 0;
      }
      const settled =
        Math.abs(om) < OMEGA_EPS && Math.abs(th - target) < THETA_EPS;
      if (pendingSettle && settled) {
        pendingSettle = false;
        setCaption(); // verdict lands on settle, never mid-swing
      }

      // ambient sway: the default look, suppressed 4s after any interaction
      ramp = suppressed
        ? Math.max(0, ramp - dt / 0.4)
        : Math.min(1, ramp + dt / 1.5);
      const sway =
        (Math.sin((now / ambPeriod) * Math.PI * 2 + ambPhase) +
          0.35 * Math.sin((now / amb2Period) * Math.PI * 2 + amb2Phase)) /
        1.35;
      const thVis = th + ramp * AMB_AMP * sway;
      render(thVis);

      // sleep: settled, no pending target, and sway suppressed — schedule a
      // timed wake for when the suppression window ends
      if (!pendingSettle && settled && suppressed && ramp === 0) {
        const remain = SUPPRESS_MS - (now - interactionAt);
        window.clearTimeout(wakeTimer);
        wakeTimer = window.setTimeout(wake, Math.max(16, remain + 30));
        last = 0;
        return; // raf stays 0 — genuinely asleep
      }
      raf = requestAnimationFrame(loop);
    };

    const wake = () => {
      if (!raf && !reduced) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    const retarget = () => {
      target = computeTarget();
      interactionAt = performance.now();
      deadlineAt = interactionAt + DEADLINE_MS;
      pendingSettle = true;
      if (reduced) {
        // discrete: beam rendered instantly at the settled angle, no sway
        th = target;
        om = 0;
        pendingSettle = false;
        render(th);
        setCaption();
        return;
      }
      wake();
    };

    engineRef.current = {
      retarget,
      setBias: (b: number) => {
        bias = b;
        retarget();
      },
    };

    // initial state
    target = computeTarget();
    if (reduced) {
      th = target;
      pendingSettle = false;
      render(th);
      setCaption();
    } else {
      render(0); // hung level, then the spring swings it to the verdict
      wake();
    }

    // -- observers -----------------------------------------------------------
    const ro = new ResizeObserver(() => {
      resize();
      render(th);
    });
    ro.observe(stage);
    ro.observe(cardL);
    ro.observe(cardR);
    const io = new IntersectionObserver((entries) => {
      onscreen = entries[0]?.isIntersecting ?? true;
      if (onscreen) wake();
    });
    io.observe(stage);
    const mo = new MutationObserver(() => {
      derive(); // live theme re-derive
      render(th);
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });
    const onVis = () => {
      if (!document.hidden) wake();
    };
    document.addEventListener("visibilitychange", onVis);

    return () => {
      cancelAnimationFrame(raf);
      raf = 0;
      window.clearTimeout(wakeTimer);
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      engineRef.current = null;
    };
    // geometry + physics are prop-independent; weights flow through modelRef
  }, []);

  const discountLabel = `−${Math.round((1 - annualMultiplier) * 100)}%`;

  const segClass = (active: boolean) =>
    active
      ? "rounded-[4px] border border-border bg-background px-3 py-1.5 font-mono text-[11px] tracking-widest text-foreground transition-colors duration-150 hover:border-foreground/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
      : "rounded-[4px] border border-transparent px-3 py-1.5 font-mono text-[11px] tracking-widest text-ns-muted transition-colors duration-150 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent";

  const renderCard = (side: "left" | "right") => {
    const tier = side === "left" ? left : right;
    const checked = side === "left" ? checkedL : checkedR;
    const setChecked = side === "left" ? setCheckedL : setCheckedR;
    const price = side === "left" ? pL : pR;
    const weight = side === "left" ? model.wL : model.wR;
    const wrapRef = side === "left" ? wrapLRef : wrapRRef;
    const cardRef = side === "left" ? cardLRef : cardRRef;
    // hover previews a +0.3° tilt bias toward this pan
    const hoverBias = (side === "left" ? -1 : 1) * (0.3 * Math.PI) / 180;

    return (
      <div
        ref={wrapRef}
        className="absolute left-0 top-0 will-change-transform"
        style={{ opacity: 0 }}
      >
        <div
          ref={cardRef}
          onPointerEnter={() => engineRef.current?.setBias(hoverBias)}
          onPointerLeave={() => engineRef.current?.setBias(0)}
          className="flex flex-col gap-4 rounded-md border border-border bg-surface p-5 transition-[translate,border-color,box-shadow] duration-200 hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-lg hover:shadow-black/25"
        >
          <header className="flex items-start justify-between gap-2">
            <div>
              <h3 className="font-mono text-xs tracking-widest text-foreground uppercase">
                {tier.name}
              </h3>
              <p className="mt-1 text-xs text-ns-muted">{tier.tagline}</p>
            </div>
            <span className="rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px] tabular-nums tracking-wider text-ns-muted">
              wt {weight.toFixed(1)}
            </span>
          </header>
          <div>
            <div className="flex items-baseline gap-1">
              <span className="text-3xl font-semibold tracking-tight tabular-nums text-foreground">
                ${price}
              </span>
              <span className="text-sm text-ns-muted">/mo</span>
            </div>
            <p className="mt-1 h-3.5 font-mono text-[10px] tracking-widest text-ns-muted uppercase">
              {billing === "annual" ? "billed annually" : "billed monthly"}
            </p>
          </div>
          <ul className="flex flex-col gap-2.5">
            {tier.features.map((f, i) => (
              <li key={f.label}>
                <label className="group flex cursor-pointer items-center gap-2.5">
                  <span className="relative inline-flex h-4 w-4 shrink-0">
                    <input
                      type="checkbox"
                      checked={checked[i] ?? false}
                      onChange={() =>
                        setChecked((prev) => {
                          const next = [...prev];
                          next[i] = !next[i];
                          return next;
                        })
                      }
                      className="peer h-4 w-4 cursor-pointer appearance-none rounded-[4px] border border-border bg-transparent transition-colors duration-150 checked:border-foreground checked:bg-foreground hover:border-foreground/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
                    />
                    <svg
                      aria-hidden
                      viewBox="0 0 12 12"
                      fill="none"
                      className="pointer-events-none absolute inset-0 m-auto h-3 w-3 text-background opacity-0 transition-opacity duration-150 peer-checked:opacity-100"
                    >
                      <path
                        d="M2.5 6.5 5 9l4.5-5.5"
                        stroke="currentColor"
                        strokeWidth="1.8"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                    </svg>
                  </span>
                  <span
                    className={`text-[13px] transition-colors duration-150 ${
                      checked[i] ? "text-foreground" : "text-ns-muted"
                    }`}
                  >
                    {f.label}
                  </span>
                </label>
              </li>
            ))}
          </ul>
          {tier.primary ? (
            <button
              type="button"
              className="mt-1 rounded-sm bg-ns-accent px-4 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
            >
              {tier.cta}
            </button>
          ) : (
            <button
              type="button"
              className="mt-1 rounded-sm border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors duration-200 hover:border-foreground/25 hover:bg-foreground/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
            >
              {tier.cta}
            </button>
          )}
        </div>
      </div>
    );
  };

  return (
    <div className={`relative w-full ${className}`}>
      <div className="mb-8 flex justify-center">
        <div
          role="group"
          aria-label="Billing period"
          className="inline-flex items-center gap-0.5 rounded-sm border border-border bg-surface p-0.5"
        >
          <button
            type="button"
            aria-pressed={billing === "monthly"}
            onClick={() => setBilling("monthly")}
            className={segClass(billing === "monthly")}
          >
            MONTHLY
          </button>
          <button
            type="button"
            aria-pressed={billing === "annual"}
            onClick={() => setBilling("annual")}
            className={segClass(billing === "annual")}
          >
            ANNUAL {discountLabel}
          </button>
        </div>
      </div>
      <div ref={stageRef} className="relative" style={{ height: 560 }}>
        <canvas
          ref={canvasRef}
          aria-hidden
          className="pointer-events-none absolute left-0 top-0"
        />
        {renderCard("left")}
        {renderCard("right")}
        <p
          ref={captionRef}
          aria-live="polite"
          className="absolute left-1/2 max-w-full -translate-x-1/2 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[11px] tracking-widest text-ns-muted uppercase"
        />
      </div>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/pricing-scale.tsx, so point the import there.

Use when a two-tier pricing section where the comparison should be settled physically, the billing toggle and each feature adding real weight until the beam tips toward whichever tier outweighs the other; pick picker-pareto-frontier instead when the options carry numeric axes and dominance should be computed rather than felt, or grid-bento-dense when the section is an overview grid rather than a two-way comparison.

Install

npx shadcn add https://design.helpmarq.com/r/pricing-scale.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Two pricing tiers rendered as literal balance-scale pans hanging from a canvas beam; billing toggle and feature checkmarks add weight and the beam settles with inertial overshoot to show which tier objectively outweighs the other, with idle ambient sway. RENDERING: a Canvas 2D layer draws fulcrum, beam, and chains as 2px strokes (--foreground beam/pivot/hooks, --border chains and fulcrum hatch), fully cleared and restroked every tick — no accumulation compositing; the two pans are real DOM pricing cards (bg-surface, rounded-md, 1px border) hung from the beam ends and positioned every frame via OFFSET transforms from the container center (translate from beam-end position + chain length, plus a small angular-velocity-based lag rotation (-clamp(ω·0.3, ±0.09 rad) about the chain attachment, zero at rest) so pans read as swinging with inertia rather than rigidly tracking θ), never absolute canvas coordinates. Canvas sized with explicit style.width/height plus a devicePixelRatio-clamped backing store. PHYSICS: beam angle integrated per frame with semi-implicit Euler: th'' = -42(th - target) - 5.5 th' (stiffness 42 s^-2, damping 5.5 s^-1 → exactly one visible overshoot, settle < 1.4s), hard forced-settle deadline snap at 2.0s so stacked rapid toggles always resolve, tilt clamped ±9°. target = clamp(0.028·(weightR - weightL)); weight = monthlyPrice/10 + enabledFeatureCount. IDLE AMBIENT (default look): ±0.4° sway, ~6s period jittered ±10% per mount plus a slow low-amplitude secondary harmonic (so it never repeats identically), ramped in and suppressed for 4s after any interaction. INTERACTION: monthly/annual segmented toggle (rounded-sm) rescales both prices and re-targets the beam; six real feature checkboxes per tier add/remove weight through the same spring; hovering a tier card lifts it 2px (token shadow, border step) and previews a +0.3° tilt bias toward it; primary CTA in --ns-accent, secondary ghost — accent appears nowhere else except focus rings. A font-mono caption under the fulcrum states the verdict ('Pro outweighs Starter by 2 features') and updates on settle, never mid-swing. REDUCED MOTION: beam and cards rendered instantly at the settled angle, no sway, discrete updates. PERF: direct-DOM rAF hot path (refs only, no React state per frame); loop sleeps when |omega| < 0.001 rad/s, no spring target pending, and sway is suppressed, with a timed wake when suppression ends; IntersectionObserver pauses offscreen and document.hidden pauses; zero-size container guard; canvas inks parsed from getComputedStyle tokens at mount with a MutationObserver on documentElement class re-deriving live; all listeners/observers/rAF/timers torn down on unmount. Demo is a full two-column pricing section: headline, muted subcopy, the scale as visual spine, Starter $19/mo vs Pro $49/mo with believable feature lists and CTAs, muted mono footnote.

Props

PropTypeDefaultDescription
left?CounterpoiseTierSTARTERtier hung from the left pan
right?CounterpoiseTierPROtier hung from the right pan
annualMultiplier?number0.8annual billing price multiplier (0.8 = 20% off)
className?string—extra classes merged onto the rendered root element