Pricing toggle

Monthly or yearly: the thumb slides with a little give, and every price on the page rolls to its new amount.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/pricing-toggle.json

Overview

The billing switch is the one control every pricing page has, and usually the dullest. Here the thumb slides and resizes to the chosen option with a little give at the end, and the labels trade colour. "Save 20%" sits inside Yearly and lights up in your brand colour when it’s chosen. Every Price on the page rolls digit by digit to its new amount, and the billing line underneath slides in from the direction you switched.

States

monthly
The thumb sits on Monthly; Yearly shows its badge in a soft tint as an incentive.
yearly
The thumb slides and widens over Yearly and its badge, and the badge fills with your brand colour.
prices
Each Price rolls to the new amount; the note ("$276 billed yearly") slides in from the toggle’s direction.

Usage

import { Price, PricingToggle } from "@/components/pricing-toggle";

const [billing, setBilling] = useState("monthly");

<PricingToggle value={billing} onValueChange={setBilling} />
<Price amount={billing === "yearly" ? 23 : 29} note={billing === "yearly" ? "$276 billed yearly" : "Billed monthly"} />

Works with

In a pricing section

One toggle drives every plan column; each Price rolls on its own.

"use client";
import { useState } from "react";
import { Price, PricingToggle } from "@/components/pricing-toggle";

const plans = [
  { name: "Starter", monthly: 12, yearly: 9 },
  { name: "Pro", monthly: 29, yearly: 23 },
  { name: "Team", monthly: 79, yearly: 63 },
];

export function Pricing() {
  const [billing, setBilling] = useState("monthly");
  const yearly = billing === "yearly";

  return (
    <section className="flex flex-col items-center gap-8">
      <PricingToggle value={billing} onValueChange={setBilling} />
      <div className="grid w-full gap-px overflow-hidden rounded-3xl border sm:grid-cols-3">
        {plans.map((plan) => (
          <div key={plan.name} className="bg-background p-6">
            <h3 className="text-sm font-medium">{plan.name}</h3>
            <Price
              amount={yearly ? plan.yearly : plan.monthly}
              direction={yearly ? 1 : -1}
              note={yearly ? `$${plan.yearly * 12} billed yearly` : "Billed monthly"}
            />
          </div>
        ))}
      </div>
    </section>
  );
}

Props

PropTypeDescription
value / onValueChangestring / (value) => voidThe chosen billing period.
options{ value; label; badge? }[]default Monthly, Yearly “Save 20%”Any number of periods, e.g. add Lifetime.
Price: amount / currency / periodnumber / string / stringdefault — / "USD" / "/mo"The figure that rolls, its currency, and the period after it (hidden at 0).
Price: note / directionstring / 1 | -1The line under the price, sliding in from the way the toggle moved.
Price: numberClassNamestringSize and weight of the figure.

Accessibility and motion

  • The toggle is a real radio group: arrow keys move between periods, and only the chosen one is in the tab order.
  • Prices are real text for screen readers; the rolling digits are decoration on top.
  • Installs Number roll alongside it.
  • With reduced motion, the thumb jumps and prices change in place.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.

The CLI also installs Number roll.

Source

›Show the full source of pricing-toggle.tsx (180 lines)
"use client";

import * as React from "react";
import { NumberRoll } from "./number-roll";

/* ─────────────────────────────────────────────────────────
 * PRICING TOGGLE: monthly or yearly, and every price rolls
 *
 *   toggle   the thumb slides and resizes to the chosen option
 *            with a little give; the labels trade colour
 *   badge    "Save 20%" sits inside Yearly and lights up in
 *            your brand colour when it's chosen
 *   prices   Price rolls each figure to its new amount, and the
 *            billing line crossfades in the direction you went
 *
 * PricingToggle is the control; Price is the number. Use them
 * together across your plan columns.
 * ───────────────────────────────────────────────────────── */

export interface BillingOption {
  value: string;
  label: string;
  /** e.g. "Save 20%". */
  badge?: string;
}

export interface PricingToggleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
  value: string;
  onValueChange: (value: string) => void;
  options?: BillingOption[];
  label?: string;
}

export interface PriceProps extends React.HTMLAttributes<HTMLDivElement> {
  amount: number;
  currency?: string;
  locales?: string | string[];
  /** e.g. "/mo"; hidden for free plans. */
  period?: string;
  /** A line under the price, e.g. "$288 billed yearly". */
  note?: string;
  /** Which way the toggle moved (1 or -1), so the note slides with it. */
  direction?: number;
  /** Classes for the figure, e.g. its size. */
  numberClassName?: string;
}

const BILLING: BillingOption[] = [
  { value: "monthly", label: "Monthly" },
  { value: "yearly", label: "Yearly", badge: "Save 20%" },
];
// A little give at the end of the slide, so the thumb feels like it was thrown, not placed.
const THROW = "cubic-bezier(0.34,1.36,0.64,1)";
const EASE = "cubic-bezier(0.23,1,0.32,1)";
const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";

const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
  const query = window.matchMedia(reducedQuery);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);

export function PricingToggle({ value, onValueChange, options = BILLING, label = "Billing period", className = "", ...props }: PricingToggleProps) {
  const reduced = useReducedMotion();
  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const [thumb, setThumb] = React.useState({ left: 0, width: 0, ready: false });
  const active = Math.max(0, options.findIndex((o) => o.value === value));

  React.useLayoutEffect(() => {
    const measure = () => {
      const el = refs.current[active];
      if (!el) return;
      const left = el.offsetLeft;
      const width = el.offsetWidth;
      setThumb((t) => (t.left === left && t.width === width ? t : { left, width, ready: t.width > 0 }));
    };
    measure();
    const observer = new ResizeObserver(measure);
    refs.current.forEach((el) => el && observer.observe(el));
    return () => observer.disconnect();
  }, [active, options]);

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
    e.preventDefault();
    const next = (active + (e.key === "ArrowRight" ? 1 : -1) + options.length) % options.length;
    onValueChange(options[next].value);
    refs.current[next]?.focus();
  };

  return (
    <div
      role="radiogroup"
      aria-label={label}
      onKeyDown={onKeyDown}
      className={`relative inline-flex items-center rounded-full border border-border bg-muted/60 p-1 ${className}`}
      {...props}
    >
      <span
        aria-hidden="true"
        className="absolute inset-y-1 left-0 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]"
        style={{
          width: thumb.width,
          transform: `translateX(${thumb.left}px)`,
          transition: thumb.ready && !reduced ? `transform 520ms ${THROW}, width 420ms ${EASE}` : "none",
        }}
      />
      {options.map((option, i) => {
        const selected = i === active;
        return (
          <button
            key={option.value}
            ref={(el) => {
              refs.current[i] = el;
            }}
            type="button"
            role="radio"
            aria-checked={selected}
            tabIndex={selected ? 0 : -1}
            onClick={() => onValueChange(option.value)}
            className={`relative flex h-8 items-center gap-2 rounded-full px-3.5 text-[13px] font-medium transition-colors duration-300 ${FOCUS} ${
              selected ? "text-foreground" : "text-muted-foreground hover:text-foreground"
            }`}
          >
            {option.label}
            {option.badge && (
              <span
                className={`rounded-full px-1.5 py-px text-[11px] font-medium transition-[background-color,color] duration-300 ${
                  selected ? "bg-primary text-primary-foreground" : "bg-primary/10 text-primary"
                }`}
              >
                {option.badge}
              </span>
            )}
          </button>
        );
      })}
    </div>
  );
}

export function Price({
  amount,
  currency = "USD",
  locales,
  period = "/mo",
  note,
  direction = 1,
  numberClassName = "text-[40px] font-medium leading-[1.15] tracking-[-0.03em] text-foreground",
  className = "",
  ...props
}: PriceProps) {
  const format = React.useMemo<Intl.NumberFormatOptions>(
    () => ({ style: "currency", currency, maximumFractionDigits: amount % 1 ? 2 : 0, minimumFractionDigits: amount % 1 ? 2 : 0 }),
    [currency, amount]
  );
  return (
    <div className={className} {...props}>
      <div className="flex items-baseline gap-1">
        <NumberRoll value={amount} format={format} locales={locales} className={numberClassName} />
        {amount > 0 && period && <span className="text-[14px] text-muted-foreground">{period}</span>}
      </div>
      {note !== undefined && (
        <div className="relative mt-1 h-5 overflow-hidden text-[12.5px] text-muted-foreground">
          <span
            key={note}
            className="absolute inset-0 truncate motion-safe:animate-[ui-note-in_380ms_cubic-bezier(0.23,1,0.32,1)_both]"
            style={{ "--ui-from": `${direction > 0 ? 100 : -100}%` } as React.CSSProperties}
          >
            {note}
          </span>
        </div>
      )}
    </div>
  );
}

Registry item: /r/pricing-toggle.json