webdevpuneet.com

Plan Selector — Free HTML CSS JS Radio Cards Snippet

Plan Selector · Pricing · Plain HTML, CSS & JS · Live preview

CategoryPricing

Copy & paste

Source Code

<section class="section">
  <div class="section-head">
    <h2 class="section-title">Choose your plan</h2>
    <p class="section-sub">Change or cancel at any time. No hidden fees.</p>
  </div>

  <div class="plans" id="plans">

    <label class="plan-card" data-plan="starter">
      <input type="radio" name="plan" value="starter" onchange="selectPlan('starter')">
      <div class="plan-inner">
        <div class="plan-check"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg></div>
        <div class="plan-name">Starter</div>
        <div class="plan-price">$0<span>/mo</span></div>
        <div class="plan-desc">Perfect for individuals and small projects just getting started.</div>
        <ul class="plan-features">
          <li>✓ 5 projects</li>
          <li>✓ 1 GB storage</li>
          <li>✓ Community support</li>
        </ul>
      </div>
    </label>

    <label class="plan-card featured" data-plan="pro">
      <input type="radio" name="plan" value="pro" checked onchange="selectPlan('pro')">
      <div class="plan-popular">Most popular</div>
      <div class="plan-inner">
        <div class="plan-check"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg></div>
        <div class="plan-name">Pro</div>
        <div class="plan-price">$12<span>/mo</span></div>
        <div class="plan-desc">For professional teams that need more power and collaboration.</div>
        <ul class="plan-features">
          <li>✓ Unlimited projects</li>
          <li>✓ 50 GB storage</li>
          <li>✓ Priority support</li>
          <li>✓ API access</li>
        </ul>
      </div>
    </label>

    <label class="plan-card" data-plan="team">
      <input type="radio" name="plan" value="team" onchange="selectPlan('team')">
      <div class="plan-inner">
        <div class="plan-check"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg></div>
        <div class="plan-name">Team</div>
        <div class="plan-price">$39<span>/mo</span></div>
        <div class="plan-desc">For growing organisations that need advanced tools and analytics.</div>
        <ul class="plan-features">
          <li>✓ Everything in Pro</li>
          <li>✓ 500 GB storage</li>
          <li>✓ Team collaboration</li>
          <li>✓ Analytics dashboard</li>
        </ul>
      </div>
    </label>

  </div>

  <div class="cta-section">
    <div class="selected-summary" id="selected-summary">
      <div class="ss-plan" id="ss-plan">Pro — $12/mo</div>
      <div class="ss-desc">Billed monthly · Cancel anytime</div>
    </div>
    <button class="cta-btn" id="cta-btn" onclick="proceedToCheckout()">
      Continue with Pro
      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>
    </button>
  </div>
</section>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

CSS: has(input:checked) — no JavaScript needed for active card state
Radio inputs hidden but functional — full keyboard navigation native
.plan-check: empty circle → filled indigo with white checkmark on :checked
plan-inner border + box-shadow transition on: has(input:checked)
Popular badge: absolute position top: -11px, translateX(-50%) centering
Summary bar: updates plan name/price and CTA button label via JavaScript
CTA button innerHTML: updates to "Continue with [Plan Name]" on each selection
Responsive: 3-column grid collapses to 1 column on mobile

About this UI Snippet

Plan Selector — Radio Card Grid, CSS :has() Active State, Summary Bar & Checkout CTA

Screenshot of the Plan Selector snippet rendered live

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.

Step by step

How to Use

  1. 1
    Click 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.
  2. 2
    Update 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.
  3. 3
    Change 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.
  4. 4
    Wire 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.
  5. 5
    Add 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.
  6. 6
    Export 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

SaaS onboarding and initial plan selection flow
Show the plan selector as the final onboarding step before account activation. The radio card format makes the choice clear and explicit — users know exactly which plan they are selecting before they click Continue.
Account upgrade and plan change settings page
Use in billing settings for plan changes. Pre-select the current plan on load. The summary bar shows what changes when a different plan is selected. The CTA changes to "Upgrade to Pro" or "Downgrade to Starter" based on the current plan relationship.
Checkout plan confirmation step
Add as the first step in a multi-step checkout: step 1 plan selection, step 2 billing details, step 3 confirmation. The selected plan value passes to the next step. The summary bar reinforces the selection at each subsequent step.
Annual vs monthly plan variant with toggle
Combine with the Pricing Toggle snippet for a complete plan selection with billing cycle choice. The toggle switches monthly/annual price arrays; the radio cards show the updated prices; the summary bar reflects both the plan and billing cycle.
Study CSS :has() for state-driven design without JavaScript
The :has() pseudo-class replaces an entire class of JavaScript patterns — styling a parent based on a descendant state. This plan selector demonstrates the pattern for radio inputs, but :has() applies to any form input state, checkbox toggles, and interactive elements.
B2B and enterprise plan selection with custom pricing
For the Enterprise plan, replace the price with "Contact sales" and wire the CTA to a Calendly link or contact form — or hand off to the dedicated enterprise pricing section. The plan selector grid handles a mix of self-serve plans (with prices) and enterprise plans (with custom CTAs) in the same visual layout.
Related: Pricing Add-On Selector
See the Pricing Add-On Selector for a related pricing pattern worth pairing with this one.
Related: Pricing Model Toggle (Per-Seat vs Per-Usage)
See the Pricing Model Toggle (Per-Seat vs Per-Usage) for a related pricing pattern worth pairing with this one.
Related: Pricing Table with Sticky Compare Bar
See the Pricing Table with Sticky Compare Bar for a related pricing pattern worth pairing with this one.
Related: Pricing ROI and Breakeven Calculator
See the Pricing ROI and Breakeven Calculator for a related pricing pattern worth pairing with this one.
Related: Role-Based Seat Mix Pricing Calculator
See the Role-Based Seat Mix Pricing Calculator for a related pricing pattern worth pairing with this one.

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.