Embrace the Default: Let HTML and CSS Do the Work
Marvin Ahlgrimm

Marvin Ahlgrimm @treagod

Location:
Germany
Joined:
Mar 21, 2024

Embrace the Default: Let HTML and CSS Do the Work

Publish Date: Jun 28
0 0

We often reach for JavaScript too quickly.
But modern HTML and CSS can already do more than you think

Maybe you know the situation. You want to provide some intuitive user interface where the user has a nice UI with huge elements, like cards, that are easy to click on. You want to make sure that the user can easily see which element is currently selected. So you reach for JavaScript and add some event listeners to change the style of the element when it is clicked.

In my case the user had to select which payment type they wanted to use. So I had a list of payment types, each represented by a nice little card. The "cash" option should be selected by default and the user could click on a card to select it. The selected card should have a different background color to nicely indicate which payment type was selected and give the user a visual feedback. Because the cards don’t contribute to the form there has to be a way to tell the form which card was selected.

Being lazy my first thought was "I need to create another Stimulus Controller to handle the click and fill a hidden input with the selected payment type. Ugh..".

But then I thought about it for a second and realized that I could do this with just HTML and CSS.

The Solution: Radio Buttons

I don't know why, but radio buttons are not in my standard repertoire of UI elements. I always think of them as something old school, something that is used in the 90s. But they are actually a great way to implement the functionality I needed.

Radio buttons are a form element that allows the user to select one option from a set. They are perfect for this use case because they inherently support the concept of a "selected" state. When you click on a radio button, it automatically updates the state of the form, and you can style the selected button differently using CSS.

So I created a list of "hidden" radio buttons and labels that reference them via the for attribute. The labels are styled as cards, and when the user clicks on a card, the corresponding radio button is selected. The selected radio button can then be styled differently using CSS.

<div class="row">
    <% payment_methods = [
        [t('payment.cash'),        "cash",            "bi-cash"],
        [t('payment.creditCard'),  "credit_card",     "bi-credit-card"],
        [t('payment.online'),      "payment_pending", "bi-credit-card"],
        ] %>

    <% payment_methods.each do |label, value, icon| %>
        <div class="col-4">
            <%= f.radio_button :payment_kind,
                                value,
                                id: "payment_kind_#{value}",
                                class: "radio-card-input" %>

            <label for="payment_kind_<%= value %>"
                    class="card radio-card">
                <i class="bi <%= icon %>"></i><br>
                <strong><%= label %></strong>
            </label>
        </div>
    <% end %>
</div>
Enter fullscreen mode Exit fullscreen mode
/* keep it in the DOM for a11y */
.radio-card-input {
  position: absolute;
  opacity: 0;                 
}

.radio-card {
  display: block;
  padding: 1rem;
  border: 1px solid var(--grey-200);
  border-radius: var(--radius);
  background: var(--white);
  text-align: center;
  cursor: pointer;
  transition: border-color .2s ease, box-shadow .2s ease;
}

.radio-card-input:checked + .radio-card {
  border-color: var(--primary);
  box-shadow: 0 0 0 4px rgba(var(--primary-rgb), .35);
}

.radio-card-input:focus + .radio-card {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}

.radio-card:hover {
  box-shadow: 0 2px 4px rgba(0,0,0,.06);
}

/* helper for nice icon sizing */
.radio-card i { font-size: 2rem; margin-bottom: .25rem; }
Enter fullscreen mode Exit fullscreen mode

This allows the user to interact with the payment options in a very intuitive way and keep it accessible. The selected payment type is automatically stored in the form, and you can easily access it when the form is submitted.

But there is more!

Toggle Visibility with CSS

This is probably something you have seen before, but I want to show it again because it is so useful. You can use the :checked pseudo-class to toggle the visibility of elements based on the state of a radio button.

In my case I wanted to show a invoice form when the user selects the online payment option. I could have used JavaScript to show and hide the form, but instead I used CSS to toggle the visibility based on the state of the radio button.

Right underneath the radio button container I put another container <div class="invoice">. By default this container is hidden:

.row .invoice {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

Then I use the :checked pseudo-class to show the container when the online payment option is selected:

.row:has(#payment_kind_payment_pending:checked) .invoice {
display: block;
}
Enter fullscreen mode Exit fullscreen mode

How does that work?

:has() is the long-awaited parent selector: it lets you style a parent (here .row) if any child inside it matches a condition – in this case, the radio button with ID payment_kind_payment_pending is :checked. After decades of “CSS can’t look upwards,” :has() finally makes it possible 🤯. It’s a relational pseudo-class which ships in every evergreen browser.

Browser support & fallback

As of mid-2025, :has() enjoys ~93 % global support (Chrome 105+, Firefox 117+, Safari 15.4+, Edge 105+).

For the minority still on older engines, the invoice panel simply stays hidden. If you need to support these too JavaScript will probably solve this.

This way the invoice form is only visible when the user selects the online payment option. No JavaScript needed! And it’s accessible out of the box too!

Comments 0 total

    Add comment