Subscription Tier Card Stack — Radio-Driven Plan Picker with Recommended Highlight
Subscription Tier Card Stack — Recommended Highlight · Cards · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="demo">
<div class="stack" id="stack">
<label class="tier-card" data-tier="starter">
<input type="radio" name="tier" value="starter" class="tier-radio" />
<div class="tier-top">
<span class="tier-name">Starter</span>
<span class="tier-price">$9<small>/mo</small></span>
</div>
<p class="tier-desc">For solo builders getting started</p>
<ul class="tier-feats">
<li>3 projects</li>
<li>1 GB storage</li>
<li>Community support</li>
</ul>
<span class="pick-indicator">Select</span>
</label>
<label class="tier-card recommended" data-tier="growth">
<input type="radio" name="tier" value="growth" class="tier-radio" checked />
<span class="rec-ribbon">Recommended</span>
<div class="tier-top">
<span class="tier-name">Growth</span>
<span class="tier-price">$29<small>/mo</small></span>
</div>
<p class="tier-desc">For growing teams that need more room</p>
<ul class="tier-feats">
<li>Unlimited projects</li>
<li>50 GB storage</li>
<li>Priority support</li>
<li>Team collaboration</li>
</ul>
<span class="pick-indicator">Select</span>
</label>
<label class="tier-card" data-tier="scale">
<input type="radio" name="tier" value="scale" class="tier-radio" />
<div class="tier-top">
<span class="tier-name">Scale</span>
<span class="tier-price">$79<small>/mo</small></span>
</div>
<p class="tier-desc">For organizations with advanced needs</p>
<ul class="tier-feats">
<li>Unlimited everything</li>
<li>500 GB storage</li>
<li>Dedicated support</li>
<li>SSO & audit logs</li>
</ul>
<span class="pick-indicator">Select</span>
</label>
</div>
<p class="picked-line">Selected plan: <strong id="pickedLabel">Growth — $29/mo</strong></p>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8fafc; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
.demo { display: flex; flex-direction: column; align-items: center; gap: 18px; }
.stack { display: flex; gap: 16px; }
.tier-card { position: relative; width: 190px; background: #fff; border: 1.5px solid #e2e8f0; border-radius: 16px; padding: 22px 18px 18px; display: flex; flex-direction: column; gap: 14px; cursor: pointer; transition: border-color 0.18s, transform 0.18s, box-shadow 0.18s; }
.tier-card:hover { border-color: #c7d2fe; transform: translateY(-2px); }
.tier-radio { position: absolute; opacity: 0; pointer-events: none; }
.tier-card.recommended { border-color: #6366f1; box-shadow: 0 10px 28px rgba(99,102,241,0.16); transform: translateY(-8px) scale(1.03); z-index: 1; }
.tier-card.recommended:hover { transform: translateY(-10px) scale(1.03); }
.rec-ribbon { position: absolute; top: -11px; left: 50%; transform: translateX(-50%); background: #6366f1; color: #fff; font-size: 10px; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; padding: 4px 12px; border-radius: 999px; box-shadow: 0 4px 10px rgba(99,102,241,0.35); }
.tier-top { display: flex; flex-direction: column; gap: 2px; }
.tier-name { font-size: 13px; font-weight: 700; color: #64748b; }
.tier-price { font-size: 24px; font-weight: 800; color: #111827; }
.tier-price small { font-size: 12px; font-weight: 600; color: #94a3b8; }
.tier-desc { font-size: 11.5px; color: #94a3b8; line-height: 1.4; min-height: 30px; }
.tier-feats { list-style: none; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.tier-feats li { font-size: 12px; font-weight: 600; color: #334155; padding-left: 18px; position: relative; }
.tier-feats li::before { content: '✓'; position: absolute; left: 0; color: #6366f1; font-weight: 800; }
.pick-indicator { text-align: center; font-size: 12px; font-weight: 700; color: #6366f1; border: 1.5px solid #e0e7ff; border-radius: 9px; padding: 8px; transition: background 0.15s, color 0.15s, border-color 0.15s; }
.tier-radio:checked ~ .pick-indicator { background: #4f46e5; color: #fff; border-color: #4f46e5; }
.tier-radio:checked ~ .pick-indicator::after { content: ' ✓'; }
.tier-radio:focus-visible ~ .pick-indicator { outline: 2px solid #6366f1; outline-offset: 2px; }
.picked-line { font-size: 13px; color: #475569; }
.picked-line strong { color: #111827; }const stack = document.getElementById('stack');
const pickedLabel = document.getElementById('pickedLabel');
const labels = {
starter: 'Starter — $9/mo',
growth: 'Growth — $29/mo',
scale: 'Scale — $79/mo',
};
stack.addEventListener('change', (e) => {
if (e.target.name !== 'tier') return;
pickedLabel.textContent = labels[e.target.value];
});Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Subscription Tier Card Stack — A Radio Group That Looks Like a Pricing Table

Pricing tier cards are usually built as clickable <div>s with a JavaScript click handler toggling a "selected" class by hand. This snippet builds the same visual pattern on top of a real native radio group instead — three <label> elements, each wrapping a hidden <input type="radio" name="tier">, so the browser's own radio semantics (mutual exclusivity, keyboard operability, form submission) do all of the selection-state work, and the only JavaScript left is a single change listener that updates a summary line.
The whole card is the label, the input just tracks state
Each .tier-card *is* a <label>, and the radio input sits inside it, visually hidden via opacity: 0 but still present and focusable. Clicking anywhere on the card — the price, the feature list, the "Select" pill — toggles that card's radio because native <label>-wrapping makes the entire element a valid click target for its child input, with zero click-handler code required for the selection mechanic itself.
How the "Select" pill reflects checked state without JS
.pick-indicator is a plain sibling <span> styled through .tier-radio:checked ~ .pick-indicator — when the radio becomes checked, this sibling selector switches the pill's background to solid indigo and appends a checkmark via content: ' ✓' on a ::after. Because this is pure CSS state, it works correctly even before any JavaScript has loaded or if JavaScript fails entirely — the radio group is a genuinely functional (if visually plain) form control on its own.
Why the recommended card is elevated, not just colored differently
The middle "Growth" card gets a combined transform: translateY(-8px) scale(1.03) plus a colored border and drop shadow, physically lifting it above its neighbors in the stack — a much stronger visual signal than color alone, especially useful for readers with color vision deficiencies who might not register a colored border as strongly as a person with typical color vision would. A small .rec-ribbon badge floats above the card's top edge for an explicit, unambiguous "Recommended" label alongside the visual elevation.
What the JavaScript actually does
The only script in this snippet listens for the group's change event and writes a plain-language summary ("Selected plan: Growth — $29/mo") to the page — a good stand-in for updating a checkout total, an order summary sidebar, or a "Continue" button's label elsewhere on a real pricing or upgrade page.
Step by step
How to Use
- 1Keep all three inputs sharing name="tier"The shared name attribute is what makes the three cards mutually exclusive as a native radio group.
- 2Move the .recommended class and rec-ribbon to any cardBoth are independent of which radio is checked by default — you can highlight a different tier than the one pre-selected.
- 3Add or remove feature <li> items freelyEach card's .tier-feats list is independent; keep counts roughly aligned for a cleaner visual comparison.
- 4Update the labels map in the JS panelThe labels object maps each radio's value to the summary text shown after selection — keep it in sync with your card prices.
- 5Read the selection on form submitBecause these are real radios, wrap the stack in a <form> and read FormData's "tier" value directly — no custom state management needed.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
The entire card is a <label> wrapping the hidden radio input, so clicking anywhere inside it — the price, the feature list, or the pill — toggles the selection via native label-to-input association.
Radios give you correct mutual exclusivity, keyboard operability (Tab plus arrow keys within the group), and native form submission for free, and remain functional even if the page's JavaScript fails to load — a button-based version would need to reimplement all of that by hand.
The pre-selected radio (checked attribute) and the .recommended styling class are independent — you can pre-check one card while visually elevating a different one, though keeping them aligned is the more common and less confusing pattern.
Yes — wrap the .stack element in a <form> and submit it normally; a FormData read or a standard POST will include the tier field with whichever radio's value is checked, exactly like any other native radio group.
The .tier-radio:checked ~ .pick-indicator CSS selector switches the pill's background, text color, and adds a checkmark via a ::after pseudo-element the moment the radio becomes checked — purely a CSS sibling-selector reaction with no script involved.
Only the plain-text "Selected plan: …" summary line beneath the cards — the visual selection state of the cards themselves is handled entirely by CSS reacting to the native radio's :checked state.