Pricing Toggle

A 3-tier pricing table with a keyboard-accessible Monthly⇄Annual switch. The whole monthly/annual price swap is driven in pure CSS by a checkbox and :has() — no JS. The 'Pro' plan floats on a gradient border.

  • :has()
  • checkbox state
  • CSS custom props
  • gradient border

Source

A single self-contained .astro component. It uses Prism's design tokens (--spectrum-*, --ink…) with sensible fallbacks, so it renders even outside this site.

cards/pricing-toggle.astro
---
export const meta = {
  title: "Pricing Toggle",
  tags: ["pricing", "toggle", "css-only", ":has()"],
  description:
    "A 3-tier pricing table with a keyboard-accessible Monthly⇄Annual switch. The whole monthly/annual price swap is driven in pure CSS by a checkbox and :has() — no JS. The 'Pro' plan floats on a gradient border.",
  tech: [":has()", "checkbox state", "CSS custom props", "gradient border"],
  height: 640,
};

const plans = [
  {
    name: "Starter",
    monthly: 9,
    annual: 7,
    blurb: "For side projects finding their feet.",
    features: ["1 project", "Community support", "1GB bandwidth", "Basic analytics"],
    featured: false,
  },
  {
    name: "Pro",
    monthly: 24,
    annual: 19,
    blurb: "For teams shipping every week.",
    features: ["Unlimited projects", "Priority support", "100GB bandwidth", "Advanced analytics", "Custom domains"],
    featured: true,
  },
  {
    name: "Scale",
    monthly: 79,
    annual: 63,
    blurb: "For products at serious volume.",
    features: ["Everything in Pro", "SSO & audit logs", "1TB bandwidth", "Dedicated manager", "99.99% SLA"],
    featured: false,
  },
];
---

<section class="pricing">
  <header class="pricing__head">
    <h2>Simple, honest pricing</h2>
    <p>Switch to annual and keep two months.</p>

    <div class="switch">
      <label class="switch__opt" for="pt-billing">Monthly</label>
      <input class="switch__input" id="pt-billing" type="checkbox" role="switch"
             aria-label="Bill annually and save" />
      <label class="switch__track" for="pt-billing" aria-hidden="true"><span class="switch__thumb"></span></label>
      <label class="switch__opt" for="pt-billing">
        Annual <span class="switch__save">-20%</span>
      </label>
    </div>
  </header>

  <div class="grid">
    {plans.map((p) => (
      <article class={`plan ${p.featured ? "plan--featured" : ""}`}>
        {p.featured && <span class="plan__badge">Most popular</span>}
        <div class="plan__inner">
          <h3 class="plan__name">{p.name}</h3>
          <p class="plan__blurb">{p.blurb}</p>

          <div class="plan__price">
            <span class="plan__currency">$</span>
            <span class="plan__amount" data-monthly={p.monthly} data-annual={p.annual}>{p.monthly}</span>
            <span class="plan__per">/mo</span>
          </div>
          <p class="plan__note">
            <span class="on-annual">Billed ${p.annual * 12}/yr — save ${(p.monthly - p.annual) * 12}</span>
            <span class="on-monthly">Billed monthly, cancel anytime</span>
          </p>

          <a href="#" class="plan__cta">Choose {p.name}</a>

          <ul class="plan__features">
            {p.features.map((f) => (
              <li>
                <svg class="check" viewBox="0 0 20 20" aria-hidden="true" width="18" height="18">
                  <path d="M4 10.5l3.5 3.5L16 5.5" fill="none" stroke="currentColor" stroke-width="2.2"
                        stroke-linecap="round" stroke-linejoin="round" />
                </svg>
                {f}
              </li>
            ))}
          </ul>
        </div>
      </article>
    ))}
  </div>
</section>

<style>
  .pricing {
    --swap: 0; /* 0 = monthly, 1 = annual */
    min-height: 640px; box-sizing: border-box;
    display: grid; gap: var(--space-l, 2rem); align-content: center;
    padding: clamp(2rem, 1rem + 4vw, 3.5rem) 1.5rem;
    background: var(--bg, #0e0f1a); color: var(--ink, #f2f2f7);
    font-family: var(--font-sans, system-ui, sans-serif);
  }

  /* ---- header + switch --------------------------------------------------- */
  .pricing__head { display: grid; gap: 0.5rem; justify-items: center; text-align: center; }
  .pricing__head h2 { margin: 0; font-size: var(--step-3, clamp(1.7rem, 1.3rem + 1.6vw, 2.4rem)); letter-spacing: -0.02em; }
  .pricing__head p { margin: 0; color: var(--ink-muted, #b7b9c9); }

  .switch { display: inline-flex; align-items: center; gap: 0.9rem; margin-top: 0.6rem; }
  .switch__opt {
    font-weight: 600; font-size: 0.95rem; cursor: pointer; user-select: none;
    color: var(--ink-muted, #b7b9c9); transition: color var(--dur-fast, 160ms) ease;
    display: inline-flex; align-items: center; gap: 0.4rem;
  }
  .switch__save {
    font-size: 0.72rem; font-weight: 700; padding: 0.15rem 0.5rem; border-radius: 999px;
    color: var(--bg, #0e0f1a);
    background: linear-gradient(100deg, var(--spectrum-4, #34d399), var(--spectrum-3, #45d3e8));
  }
  /* the real control — sits under the visual track, still focusable */
  .switch__input {
    position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
    overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
  }
  .switch__track {
    position: relative; width: 58px; height: 32px; border-radius: 999px; cursor: pointer;
    background: var(--surface-2, #24252f); border: 1px solid var(--border, #33343f);
    transition: background var(--dur, 320ms) var(--ease-out, ease);
  }
  .switch__thumb {
    position: absolute; top: 3px; left: 3px; width: 24px; height: 24px; border-radius: 50%;
    background: linear-gradient(160deg, #fff, #dfe3ee);
    box-shadow: 0 2px 6px rgb(0 0 0 / 0.35);
    transition: transform var(--dur, 320ms) var(--ease-spring, cubic-bezier(0.34,1.56,0.64,1));
  }
  /* focus ring on the visual track when the hidden input is focused */
  .switch__input:focus-visible + .switch__track {
    outline: 2px solid var(--spectrum-3, #45d3e8); outline-offset: 3px;
  }

  /* ---- PURE-CSS state swap ----------------------------------------------- */
  .pricing:has(.switch__input:checked) { --swap: 1; }
  .pricing:has(.switch__input:checked) .switch__track {
    background: linear-gradient(100deg, var(--spectrum-1, #8b5cf6), var(--spectrum-3, #45d3e8));
  }
  .pricing:has(.switch__input:checked) .switch__thumb { transform: translateX(26px); }
  /* bold the active label */
  .pricing:not(:has(.switch__input:checked)) .switch__opt:first-of-type,
  .pricing:has(.switch__input:checked) .switch__opt:last-of-type { color: var(--ink, #fff); }

  /* price note swap */
  .plan__note .on-annual { display: none; }
  .pricing:has(.switch__input:checked) .plan__note .on-monthly { display: none; }
  .pricing:has(.switch__input:checked) .plan__note .on-annual { display: inline; }

  /* the number itself: counter-driven from data-* so it truly changes value */
  .plan__amount { position: relative; }
  .plan__amount::after {
    content: attr(data-monthly);
  }
  .pricing:has(.switch__input:checked) .plan__amount::after { content: attr(data-annual); }
  /* hide the SSR text node, show the ::after value (keeps a sensible no-CSS fallback) */
  .plan__amount { font-size: 0; }
  .plan__amount::after { font-size: var(--step-5, clamp(2.4rem, 1.9rem + 2.4vw, 3.8rem)); }

  /* ---- grid + cards ------------------------------------------------------ */
  .grid {
    display: grid; gap: 1.25rem; width: min(64rem, 100%); margin-inline: auto;
    grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); align-items: stretch;
  }
  .plan {
    position: relative; border-radius: var(--radius-l, 22px); padding: 1px;
    background: var(--border, #2a2b36);
    transition: transform var(--dur, 320ms) var(--ease-out, ease);
  }
  .plan__inner {
    height: 100%; box-sizing: border-box; border-radius: calc(var(--radius-l, 22px) - 1px);
    background: var(--surface, #16171f); padding: 1.6rem 1.4rem;
    display: grid; gap: 0.65rem; align-content: start;
  }
  .plan__name { margin: 0; font-size: 1.15rem; font-weight: 700; letter-spacing: 0.02em; }
  .plan__blurb { margin: 0; color: var(--ink-muted, #b7b9c9); font-size: 0.9rem; min-height: 2.6em; }
  .plan__price { display: flex; align-items: baseline; gap: 0.15rem; margin-top: 0.3rem; }
  .plan__currency { font-size: 1.4rem; font-weight: 700; color: var(--ink-muted, #b7b9c9); }
  .plan__amount, .plan__amount::after { font-weight: 800; letter-spacing: -0.03em; line-height: 1; }
  .plan__per { font-size: 0.9rem; color: var(--ink-faint, #8a8c9c); align-self: flex-end; margin-bottom: 0.4rem; }
  .plan__note { margin: 0; font-size: 0.8rem; color: var(--ink-faint, #8a8c9c); min-height: 1.2em; }

  .plan__cta {
    margin-top: 0.5rem; text-align: center; text-decoration: none; font-weight: 600;
    padding: 0.7rem 1rem; border-radius: 999px;
    color: var(--ink, #fff); border: 1px solid var(--border-strong, #45465a);
    background: var(--surface-2, #20212b);
    transition: transform var(--dur-fast, 160ms) var(--ease-spring, ease), background var(--dur-fast, 160ms) ease;
  }
  .plan__cta:hover { transform: translateY(-2px); background: var(--surface, #26273400); }

  .plan__features { list-style: none; margin: 0.6rem 0 0; padding: 0; display: grid; gap: 0.55rem; }
  .plan__features li { display: flex; align-items: center; gap: 0.6rem; font-size: 0.9rem; color: var(--ink-muted, #cfd1de); }
  .check { flex: none; color: var(--spectrum-3, #45d3e8); }

  /* ---- featured plan ----------------------------------------------------- */
  .plan--featured {
    background: linear-gradient(140deg, var(--spectrum-1, #8b5cf6), var(--spectrum-3, #45d3e8), var(--spectrum-5, #fbbf24));
    box-shadow: var(--shadow-l, 0 20px 50px rgb(0 0 0 / 0.4)), 0 0 40px -10px var(--spectrum-1, #8b5cf6);
  }
  @media (min-width: 52rem) {
    .plan--featured { transform: scale(1.04); }
    .plan--featured:hover { transform: scale(1.04) translateY(-3px); }
  }
  .plan--featured .plan__cta {
    border-color: transparent; color: var(--bg, #0e0f1a);
    background: linear-gradient(100deg, #fff, #e9ecf7);
  }
  .plan--featured .check { color: var(--spectrum-1, #b79bff); }
  .plan__badge {
    position: absolute; top: 0; left: 50%; transform: translate(-50%, -50%);
    font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
    padding: 0.3rem 0.8rem; border-radius: 999px; z-index: 1; color: var(--bg, #0e0f1a);
    background: linear-gradient(100deg, var(--spectrum-5, #fbbf24), var(--spectrum-6, #fb7185));
    box-shadow: 0 4px 12px rgb(0 0 0 / 0.3);
  }

  .plan:not(.plan--featured):hover { transform: translateY(-3px); }

  @media (prefers-reduced-motion: reduce) {
    .switch__thumb, .plan, .plan__cta, .switch__track { transition: none; }
  }
</style>