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.
---
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>