webdevpuneet.com

CSS Only Styled Radio Buttons — Custom Plan Selector, No JavaScript

Styled Radio Buttons — CSS Only Plan Selector (No JavaScript) · Forms · Plain HTML & CSS · Live preview

CategoryForms

Copy & paste

Source Code

<div class="demo">
  <form class="plan-form">
    <div class="plan-option">
      <input type="radio" name="plan" id="planFree" class="plan-input" checked />
      <label for="planFree" class="plan-card">
        <span class="plan-dot"></span>
        <span class="plan-body">
          <span class="plan-name">Free</span>
          <span class="plan-desc">1 project, community support</span>
        </span>
        <span class="plan-price">$0</span>
      </label>
    </div>
    <div class="plan-option">
      <input type="radio" name="plan" id="planPro" class="plan-input" />
      <label for="planPro" class="plan-card">
        <span class="plan-dot"></span>
        <span class="plan-body">
          <span class="plan-name">Pro</span>
          <span class="plan-desc">Unlimited projects, priority support</span>
        </span>
        <span class="plan-price">$12/mo</span>
      </label>
    </div>
    <div class="plan-option">
      <input type="radio" name="plan" id="planTeam" class="plan-input" />
      <label for="planTeam" class="plan-card">
        <span class="plan-dot"></span>
        <span class="plan-body">
          <span class="plan-name">Team</span>
          <span class="plan-desc">Everything in Pro, plus SSO &amp; seats</span>
        </span>
        <span class="plan-price">$29/mo</span>
      </label>
    </div>
  </form>
</div>

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.

· Become a supporter

What's included

Features

Real, submittable <input type="radio"> elements — not a JS-simulated selection widget
Entire card visual state driven by .plan-input: checked + .plan-card, a single sibling-combinator rule
Native mutual exclusivity via shared name attribute — no manual deselect-others logic
Animated dot fill using transform: scale() on an always-present ::after pseudo-element
:focus-visible on the hidden input styles the visible label sibling for a real accessible focus ring
Input hidden via opacity/pointer-events, preserving tab order, form submission, and screen-reader semantics
Zero JavaScript — the same technique extends to any :checked/:focus-visible/:disabled state styling
Works inside CMS raw-HTML blocks and script-stripped embeds since no script executes

About this UI Snippet

Custom-Styled Radio Buttons — Pure CSS :checked Sibling Styling

Screenshot of the Styled Radio Buttons — CSS Only Plan Selector (No JavaScript) snippet rendered live

Native <input type="radio"> elements can be given some cosmetic styling with accent-color, but that only recolors the browser's built-in circle — it can't turn the whole surrounding card into a selectable, visually-rich control. This snippet takes the opposite approach: hide the native input entirely and drive every visual aspect of a much larger custom control from its state.

The hide-and-restyle pattern

Each radio is positioned absolute with opacity: 0; pointer-events: none, removing it visually and from the click-hit-testing path, while leaving it in the DOM, in the tab order, and fully functional as a real form control — this matters because the form still submits a real radio value on submit, with no hidden-field synchronization trick required, unlike a from-scratch JS-only widget that has to manufacture its own form data.

Sibling combinator, not descendant, and why order matters

Every dynamic style hangs off .plan-input:checked + .plan-card — the adjacent-sibling combinator +, not the general sibling ~ used elsewhere in this batch. + matches only the *next* element immediately following, which is exactly the relationship here: the <label class="plan-card"> sits directly after its <input> in the same .plan-option wrapper, with nothing between them. This is also why the input must come *before* the label in markup order — CSS combinators only look forward through following siblings, never backward.

The dot's fill uses transform, not a background swap

.plan-dot::after is an always-present, initially scale(0) filled circle. On :checked, it scales to 1. Animating a transform: scale() rather than toggling display or swapping a background color lets the fill grow outward with a spring-like cubic-bezier easing curve — a purely visual polish detail that costs nothing extra in markup since the same ::after element exists whether checked or not; only its scale changes.

Group semantics still come from the browser

All three radios share name="plan", so checking one automatically unchecks the others — the same native mutual-exclusivity guarantee used by the tab-switcher snippet in this batch, here applied to an actual form input group rather than a purely presentational switch. That distinction matters: this component is a real form field a server can read from FormData, not a UI-only simulation of one.

Accessible focus without extra markup

.plan-input:focus-visible + .plan-card puts a visible focus outline on the *card*, not the invisible input, whenever the input receives keyboard focus and the browser determines focus should be visibly indicated (as opposed to a mouse click, which typically doesn't trigger :focus-visible). This is the same sibling-combinator trick used for the checked state, just keyed to a different pseudo-class — proof that once the hide-and-restyle pattern is in place, *any* native pseudo-class on the input (:checked, :focus-visible, :disabled, :invalid) becomes available to style the visible sibling with, for free.

Where this is the only option

Rich, custom-styled form controls are common in pricing pages and account settings screens, both of which are sometimes rendered through CMS "raw HTML" blocks or A/B testing tools that inject markup without executing arbitrary JavaScript for security reasons. Because every interactive behavior here — selection, mutual exclusivity, focus indication — comes from native input semantics plus CSS selectors, the control remains fully operable, and remains a genuine submittable form field, in exactly those constrained rendering contexts.

Step by step

How to Use

  1. 1
    Keep the input immediately before its labelThe .plan-input:checked + .plan-card rule requires the label to be the very next element after its radio input in the DOM.
  2. 2
    Share one name across the groupAll radios in a group must use the same name attribute so the browser enforces single selection automatically.
  3. 3
    Wrap each pair in a positioning containerEach .plan-option wrapper gives the absolutely-positioned input a sizing context without affecting the label's own layout.
  4. 4
    Customize the card contentSwap plan-name/plan-desc/plan-price text and add or remove .plan-option blocks to fit a different form — the CSS applies uniformly to any count.
  5. 5
    Submit as a normal formBecause these are real radio inputs, wrapping them in a <form> and adding a submit button produces normal, server-readable form data — no JS collection step needed.

Real-world uses

Common Use Cases

SHOP
Pricing Plan Selectors
Card-style plan pickers on checkout or signup pages, submittable as a genuine form field with no JS glue
Settings & Preferences Panels
Any single-choice setting (theme, notification frequency, shipping method) styled beyond the native radio circle
Onboarding Survey Steps
Multiple-choice onboarding questions where each option needs a richer visual treatment than a bare radio dot
CMS-Embedded Order Forms
Forms authored inside CMS raw-HTML blocks that strip scripts but still need real, submittable custom-styled inputs
Learning the Hide-and-Restyle Pattern
A clean reference for restyling any native input by hiding it and keying sibling selectors to its pseudo-classes
Related: Dual Range Price Filter
See the Dual Range Price Filter for a related forms pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

It is a real <input type="radio"> — screen readers announce it correctly, it participates in the native radio-group keyboard navigation, and it submits actual form data. Only its default visual rendering is hidden; its semantics are untouched.

The + (adjacent sibling) combinator matches only the element immediately following, which precisely matches the input-then-label structure used here. ~ (general sibling) would also work since there is only one label per input, but + is more explicit about the intended one-to-one relationship.

Yes — replace type="radio" with type="checkbox" and drop the shared name if independent selection is desired; every :checked-driven rule works identically since :checked applies to both input types.

Add :invalid or :required styling using the same sibling-combinator trick, e.g. .plan-form:has(.plan-input:required:not(:checked)) .plan-card — or more simply, style a :invalid state on the fieldset if you wrap the group in one with required semantics.

No — it is the more accessible choice. display:none removes an element from the accessibility tree in most browsers, while opacity:0 with pointer-events:none keeps it visually invisible but still focusable, tabbable, and announced correctly by screen readers.