More Pricing Snippets
Plan Selector — Free HTML CSS JS Radio Cards Snippet
Plan Selector · Pricing · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Plan Selector — Radio Card Grid, CSS :has() Active State, Summary Bar & Checkout CTA

A plan selector combines the information density of a comparison table with the interaction clarity of radio buttons. Users see all plans side by side, click to select, and proceed with a single CTA. This pattern is increasingly used for upgrading, onboarding plan selection, and billing settings pages where users need to choose one plan from several options. This snippet provides a complete plan selector: three radio card inputs with CSS :has() active state, an animated check indicator, a popular badge, feature lists, and a summary bar that updates as the user selects different plans.
The CSS :has() selector for radio card state
The key technique: .plan-card:has(input:checked) .plan-inner targets the card's inner div when the hidden radio input inside it is checked. This replaces the older JavaScript class-toggle pattern entirely — no click handler needed to apply the selected visual style. :has() is supported in all modern browsers (Chrome 105+, Safari 15.4+, Firefox 121+). The border, box-shadow, and check indicator all update automatically when the radio changes.
The floating check indicator
Each .plan-check starts as an empty circle with a grey border and transparent SVG checkmark. When the card is selected (.plan-card:has(input:checked) .plan-check), the background becomes indigo and the SVG becomes white via color: #fff. The SVG inherits the text colour, making this a single CSS property change rather than multiple fill/stroke updates.
The summary bar
The bottom bar shows the selected plan name and price, updated by the JavaScript selectPlan() function. The CTA button label also updates — "Continue with Pro", "Continue with Starter" — so users always know which plan they are confirming. This reduces the cognitive load of remembering which radio they clicked.
Keyboard accessibility
Since the plan cards are label elements wrapping radio inputs, full keyboard navigation works natively: Tab to focus any radio, arrow keys to move between radios, Space to select. No JavaScript keyboard handling needed.
Connecting to Stripe
In proceedToCheckout(), replace the alert with stripe.redirectToCheckout({ priceId: PLAN_PRICE_IDS[selected] }) or a navigation to your checkout URL with the plan as a query parameter.
Handling the :has() browser support fallback
CSS :has() is supported in Chrome 105+, Safari 15.4+, and Firefox 121+. For older browser support, add a JavaScript fallback: document.querySelectorAll(".plan-card input").forEach(input => input.addEventListener("change", () => { document.querySelectorAll(".plan-card").forEach(c => c.classList.toggle("is-checked", c.contains(input))); })). Add .plan-card.is-checked .plan-inner styles alongside the :has() rules.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You don't have to puzzle out the CSS-only state trick on your own. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how the selector plan-card colon has open paren input colon checked close paren targets a parent based on a child's state, and why that removes the need for a JavaScript click handler just to toggle the active-card styling. The same assistant can help optimize it, for example checking whether the PLANS lookup object and the selectPlan function stay in sync if a fourth plan is added, or whether a JavaScript fallback is worth adding for browsers that predate wide :has() support. It's also useful for extending the effect: ask it to add an annual/monthly billing toggle that swaps every plan's displayed price, wire proceedToCheckout into a real Stripe Checkout session, or make the "Most popular" badge configurable per plan instead of hardcoded to one card. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a pricing plan selector in plain HTML, CSS, and vanilla JavaScript that uses the CSS :has() pseudo-class to style the selected card, with no JavaScript class-toggling for the active state.
Requirements:
- Three plan cards, each a label element wrapping a visually hidden (not display:none) radio input sharing the same name attribute, so only one can be selected at a time and the whole card is a native, keyboard-operable form control.
- Style the selected card's visual container using a CSS selector of the form .plan-card:has(input:checked) targeting descendants, applying a distinct border color and box-shadow — with zero JavaScript required to add or remove that styling when the radio's checked state changes.
- A small circular check indicator inside each card that is an empty outlined circle by default and becomes a filled, colored circle with a visible checkmark only when that card's radio is checked, again driven purely by the :has() selector rather than a class toggle.
- One card marked as the recommended/most-popular option with a floating badge label positioned above the card.
- A summary bar below the grid of cards that displays the currently selected plan's name and price and a "Continue with [Plan Name]" button whose label text updates via JavaScript whenever a different radio's change event fires.
- A checkout button click handler that, for now, can just report which plan was selected, but is written to be easily replaced with a real payment provider's checkout redirect using the selected plan's price identifier.Step by step
How to Use
- 1Click any plan card to select itThe selected card gets an indigo border and glow. The check indicator fills. The summary bar at the bottom updates to show the selected plan name and price. Click "Continue with X" to proceed.
- 2Update plan names, prices, and featuresEdit the plan-name, plan-price, and plan-features list inside each .plan-card label. Update the PLANS object in JS to match the names and prices for the summary bar and CTA button.
- 3Change the featured/popular planMove the class="plan-card featured" and the .plan-popular badge div to whichever plan you want to highlight as recommended. The CSS :has() active state works on all three cards equally.
- 4Wire the checkout button to StripeIn proceedToCheckout(), replace the alert with stripe.redirectToCheckout({ priceId: PLAN_PRICE_IDS[selected] }). Define PLAN_PRICE_IDS = { starter: "price_xxx", pro: "price_yyy", team: "price_zzz" } mapping plan values to Stripe price IDs.
- 5Add annual/monthly toggleSee the Pricing Toggle snippet in this library. Add it above the plan grid. Wire it to update the plan prices in both the plan-price elements and the PLANS object. Use two price sets: MONTHLY_PLANS and ANNUAL_PLANS, swapping on toggle.
- 6Export in your formatClick "HTML" for a standalone file, "JSX" for a React component with useState for selected plan, or "Tailwind" for a React + Tailwind CSS version.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
.plan-card:has(input:checked) .plan-inner selects the .plan-inner element inside any .plan-card that contains a checked input. When the hidden radio input becomes checked (via click on the label or keyboard selection), the browser automatically re-evaluates :has() and applies the border, box-shadow, and check indicator styles. No click handler, no classList.add(), no JavaScript — the CSS responds to the native radio state change in real time.
Add the checked attribute to the radio input of the plan you want pre-selected: <input type="radio" name="plan" value="pro" checked>. In the JavaScript, update the initial selected variable: let selected = "pro". Also call selectPlan("pro") at the bottom of the script to sync the summary bar with the pre-selected plan on page load.
Define price IDs: const STRIPE_PRICES = { starter: "price_starter_monthly", pro: "price_pro_monthly", team: "price_team_monthly" }. In proceedToCheckout(): const stripe = Stripe("pk_live_xxx"); stripe.redirectToCheckout({ lineItems: [{ price: STRIPE_PRICES[selected], quantity: 1 }], mode: "subscription", successUrl: window.location.origin + "/success", cancelUrl: window.location.href }). Load the Stripe.js script in the HTML head.
Click "JSX" to download. Replace the radio inputs with React-controlled inputs: <input type="radio" checked={selected === "pro"} onChange={() => setSelected("pro")}>. The CSS :has() selector still works with React-rendered HTML — it responds to the DOM checked state regardless of how it was set. Manage selected with useState("pro"). Derive the summary bar content from the selected state value using the PLANS object.