Skip to content

Collection 01 / Interactive

Pricing Switch.

Responsive pricing cards with monthly and yearly billing. Clear prices, considered details.

Live playground

Accent

Simple pricing

Small start. Big possibilities.

Billing period

Starter

A little room to explore.

$12/ month

$12 billed each month

  • 3 active projects
  • Unlimited ideas
  • Community support
Get started

Studio

Popular

For your next big thing.

$29/ month

$29 billed each month

  • Unlimited projects
  • Shared workspaces
  • Priority support
Get started
import { PricingSwitch } from "@/components/gear5/pricing-switch";

export default function Example() {
  return <PricingSwitch accent={"#d9fc87"} />;
}

Make it yours.

npx shadcn@latest add https://gear5-ui.vercel.app/r/pricing-switch.json

Copies editable source into your project. Setup guide · React and Tailwind CSS 4 required.

Put it to work.

import { PricingSwitch } from "@/components/gear5/pricing-switch";
export default function Pricing() {
  return <PricingSwitch plans={[{
    id: "pro", name: "Pro", description: "For your next big thing.",
    monthlyPrice: "$29", yearlyPrice: "$24",
    monthlyNote: "$29 billed each month", yearlyNote: "$288 billed once a year",
    features: ["Unlimited projects", "Priority support"],
    href: "/signup", featured: true,
  }]} />;
}

Good to know.

  • Monthly and yearly controls use native radio buttons with keyboard support.
  • Supply price strings and billing notes explicitly, in your customer’s language and currency.
  • This block displays your plans. Connect the href for each plan to your own checkout or enquiry page.

Source & props.

Installer-ready imports. For manual installation, copy this file into components/gear5/pricing-switch.tsx and add the helpers below at their shown paths.

"use client";

import { useId, useState, type CSSProperties } from "react";
import { sanitizeHref } from "@/lib/gear5/sanitize";

export interface PricingPlan {
  id: string;
  name: string;
  description: string;
  monthlyPrice: string;
  yearlyPrice: string;
  monthlyNote: string;
  yearlyNote: string;
  features: string[];
  href: string;
  featured?: boolean;
}
const plansDefault: PricingPlan[] = [
  {
    id: "starter",
    name: "Starter",
    description: "A little room to explore.",
    monthlyPrice: "$12",
    yearlyPrice: "$9",
    monthlyNote: "$12 billed each month",
    yearlyNote: "$108 billed once a year",
    features: ["3 active projects", "Unlimited ideas", "Community support"],
    href: "#contact",
  },
  {
    id: "studio",
    name: "Studio",
    description: "For your next big thing.",
    monthlyPrice: "$29",
    yearlyPrice: "$24",
    monthlyNote: "$29 billed each month",
    yearlyNote: "$288 billed once a year",
    features: ["Unlimited projects", "Shared workspaces", "Priority support"],
    href: "#contact",
    featured: true,
  },
];
export interface PricingSwitchProps {
  plans?: PricingPlan[];
  title?: string;
  eyebrow?: string;
  monthlyLabel?: string;
  yearlyLabel?: string;
  billingLabel?: string;
  unitLabel?: string;
  actionLabel?: string;
  featuredLabel?: string;
  accent?: string;
  className?: string;
}

/** Prices and billing notes are explicit caller data; no checkout or automatic billing. */
export function PricingSwitch({
  plans = plansDefault,
  title = "Small start. Big possibilities.",
  eyebrow = "Simple pricing",
  monthlyLabel = "Monthly",
  yearlyLabel = "Yearly",
  billingLabel = "Billing period",
  unitLabel = "/ month",
  actionLabel = "Get started",
  featuredLabel = "Popular",
  accent = "#d9fc87",
  className = "",
}: PricingSwitchProps) {
  const id = useId();
  const [yearly, setYearly] = useState(false);
  return (
    <section
      style={{ "--g5-accent": accent } as CSSProperties}
      className={`rounded-3xl bg-[#111214] p-6 text-[#f4f4ef] sm:p-10 ${className}`}
    >
      <p className="font-mono text-[11px] tracking-[.12em] text-[#b4b6af] uppercase">
        {eyebrow}
      </p>
      <h2 className="mt-3 text-3xl font-medium tracking-[-.05em] sm:text-4xl">
        {title}
      </h2>
      <fieldset className="mt-7 flex w-fit gap-1 rounded-full border border-white/20 p-1">
        <legend className="sr-only">{billingLabel}</legend>
        {[
          [false, monthlyLabel],
          [true, yearlyLabel],
        ].map(([value, label]) => (
          <label
            key={String(value)}
            className={`relative cursor-pointer rounded-full px-5 py-2.5 text-sm transition-colors focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-white motion-reduce:transition-none ${yearly === value ? "bg-[var(--g5-accent)] text-[#111214]" : "text-[#c2c4bd]"}`}
          >
            <input
              type="radio"
              name={`${id}-billing`}
              checked={yearly === value}
              onChange={() => setYearly(Boolean(value))}
              className="sr-only"
            />
            {label}
          </label>
        ))}
      </fieldset>
      <div className="mt-7 grid gap-4 sm:grid-cols-2">
        {plans.map((plan) => (
          <article
            key={plan.id}
            className={`relative flex flex-col rounded-2xl border p-6 ${plan.featured ? "border-[var(--g5-accent)] bg-[#1b2017]" : "border-white/15 bg-[#181a1c]"}`}
          >
            <div className="flex items-center justify-between gap-3">
              <h3 className="text-lg font-medium">{plan.name}</h3>
              {plan.featured && (
                <span className="rounded-full bg-[var(--g5-accent)] px-3 py-1 text-[10px] font-semibold text-[#111214]">
                  {featuredLabel}
                </span>
              )}
            </div>
            <p className="mt-2 text-sm text-[#b4b6af]">{plan.description}</p>
            <div aria-live="polite" aria-atomic="true" className="my-7">
              <p className="text-sm text-[#b4b6af]">
                <span className="me-2 text-5xl font-medium tracking-[-.06em] text-[#f4f4ef]">
                  {yearly ? plan.yearlyPrice : plan.monthlyPrice}
                </span>
                {unitLabel}
              </p>
              <p className="mt-3 text-xs text-[#b4b6af]">
                {yearly ? plan.yearlyNote : plan.monthlyNote}
              </p>
            </div>
            <ul className="mb-7 space-y-3 text-sm text-[#d5d6cf]">
              {plan.features.map((feature) => (
                <li key={feature} className="flex gap-3">
                  <span aria-hidden="true" className="text-[var(--g5-accent)]">
                    ✓
                  </span>
                  {feature}
                </li>
              ))}
            </ul>
            <a
              href={sanitizeHref(plan.href)}
              className={`mt-auto flex min-h-11 items-center justify-between rounded-full px-5 py-3 text-sm font-medium transition-opacity hover:opacity-80 motion-reduce:transition-none ${plan.featured ? "bg-[var(--g5-accent)] text-[#111214]" : "border border-white/20 text-[#f4f4ef]"}`}
            >
              {actionLabel}
              <span aria-hidden="true">↗</span>
            </a>
          </article>
        ))}
      </div>
    </section>
  );
}

Helper: sanitize → lib/gear5/sanitize.ts
/**
 * Security primitive: neutralise the one class of URL that turns "render a
 * link" into "run arbitrary script" — `javascript:` and other executable
 * schemes reaching an `href`/`src` from data the app did not author itself
 * (a CMS field, a query param, a user's own profile link).
 */

const SAFE_SCHEMES = new Set(["http:", "https:", "mailto:", "tel:", "sms:"]);

/**
 * Returns `url` unchanged if its scheme is safe to navigate to, or `"#"`
 * otherwise. Relative URLs (no scheme) are always safe and pass through.
 */
export function sanitizeHref(url: string): string {
  const trimmed = url.trim();
  if (!trimmed) return "#";

  // Browsers discard tabs and newlines when parsing a URL scheme.
  const schemeProbe = trimmed.replace(/[\t\r\n]/g, "");
  if (!/^[a-z][a-z0-9+.-]*:/i.test(schemeProbe)) return trimmed;

  try {
    const scheme = new URL(trimmed, "https://example.com").protocol;
    return SAFE_SCHEMES.has(scheme) ? trimmed : "#";
  } catch {
    return "#";
  }
}

VERIFIED: AXE FIXTURES / SERVER RENDER / SOURCE SCANS / 3300 B GZIP CEILING
Test your own content and screen-reader flows before shipping.