Pure CSS techniques — no JavaScript required
Customizing radio buttons and checkboxes is still tricky. Coding agents can write both vanilla CSS and Tailwind, but these still often need to be tweaked by a human with eyes and some common sense.
You don't need to get complicated. We don't need frameworks on frameworks on frameworks until we get into the craziness described in The Incredible Overcomplexity of the Shadcn Radio Button.
But there are still tradeoffs involved in using vanilla CSS versus a framework like Tailwind. Agents and humans can do both. Here are some examples for you to understand the differences, or steal to iterate in your own projects in a few lines.
Use the tabs to compare the same 10 UI cards. Both sets are intentionally matched so you can scan differences in structure, readability, and maintainability without style noise.
Basic radios are the foundation for every other style. The recipe is consistent: keep the native input for accessibility, hide it visually, and render a custom circle that reflects the checked state.
Vanilla CSS keeps the HTML clean and moves the behavior into one or two classes. You connect the input and label with for, then use :checked to toggle the inner dot.
<input type="radio" name="group" id="opt-a" class="custom-input" checked>
<label for="opt-a" class="basic-label">
<span class="basic-radio-mark"></span>
Option A
</label>
.custom-input {
position: absolute;
opacity: 0;
}
.basic-radio-mark {
width: 18px;
height: 18px;
border: 2px solid #d1d5db;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.basic-radio-mark::after {
content: '';
width: 8px;
height: 8px;
background: #6b7280;
border-radius: 50%;
transform: scale(0);
transition: transform 0.15s;
}
.custom-input:checked + .basic-label .basic-radio-mark::after {
transform: scale(1);
}
Tailwind uses the same structure but replaces custom selectors with utility classes. The input becomes a peer, and the dot is toggled using peer-checked utilities.
<label class="flex items-center gap-2 py-1 text-sm text-slate-600">
<input type="radio" name="group" class="sr-only peer" checked>
<span class="relative flex h-[18px] w-[18px] items-center justify-center rounded-full border-2 border-slate-300 transition peer-checked:border-slate-500 after:content-[''] after:block after:h-2 after:w-2 after:rounded-full after:bg-slate-500 after:scale-0 after:transition-transform peer-checked:after:scale-100"></span>
Option A
</label>
Choice note: vanilla CSS keeps markup short and reuses a single class, while Tailwind pushes the detail into HTML. If your team prefers lean HTML, vanilla is easier to scan. If you value quick iteration in markup, Tailwind is faster to tweak.
Color is a common search term for radio customization. The easiest pattern is to set text color and let the mark inherit via currentColor, which keeps the HTML tiny and avoids duplicated palettes.
Vanilla CSS shines here because you can define named color classes once and apply them anywhere. The radio mark inherits the text color automatically.
<input type="radio" name="group" id="rose" class="custom-input" checked>
<label for="rose" class="color-label rose">
<span class="color-radio-mark"></span>
Rose
</label>
.rose { color: #e11d48; }
.emerald { color: #059669; }
.color-radio-mark {
width: 18px;
height: 18px;
border: 2px solid currentColor;
border-radius: 50%;
}
.color-radio-mark::after {
content: '';
width: 8px;
height: 8px;
background: currentColor;
border-radius: 50%;
transform: scale(0);
transition: transform 0.15s;
}
.custom-input:checked + .color-label .color-radio-mark::after {
transform: scale(1);
}
Tailwind keeps the palette in class names. You can swap colors per instance without editing a CSS file, which is handy for one-off palettes or rapid exploration.
<label class="flex items-center gap-2 py-1 text-sm font-medium text-rose-600">
<input type="radio" name="group" class="sr-only peer" checked>
<span class="relative flex h-[18px] w-[18px] items-center justify-center rounded-full border-2 border-current transition after:content-[''] after:block after:h-2 after:w-2 after:rounded-full after:bg-current after:scale-0 after:transition-transform peer-checked:after:scale-100"></span>
Rose
</label>
Choice note: vanilla makes it easy to centralize a palette with semantic class names, while Tailwind makes per-instance color changes effortless. If consistency is the priority, vanilla wins; if speed of iteration is the priority, Tailwind wins.
Icons help radios feel more descriptive, especially for “featured” or “favorite” choices. The pattern is the same as before, but the checked state swaps in a glyph.
With vanilla CSS you can keep the markup minimal and use ::after to inject icons. It is clean and reusable, especially if you want to standardize icon sizing and alignment.
<input type="radio" name="group" id="feat" class="custom-input" checked>
<label for="feat" class="icon-label">
<span class="icon-radio-mark star-icon"></span>
Featured
</label>
.star-icon { border-color: #0ea5e9; color: #0ea5e9; }
.star-icon::after { content: '★'; }
.heart-icon { border-color: #ec4899; color: #ec4899; }
.heart-icon::after { content: '♥'; }
.icon-radio-mark::after {
font-size: 0;
transition: font-size 0.15s;
}
.custom-input:checked + .icon-label .icon-radio-mark {
background: currentColor;
}
.custom-input:checked + .icon-label .icon-radio-mark::after {
font-size: 10px;
color: white;
}
Tailwind keeps icons explicit in the markup. This is slightly more verbose, but it also makes the icon choice obvious to anyone reading the HTML.
<label class="flex items-center gap-2 py-1 text-sm text-slate-600">
<input type="radio" name="group" class="sr-only peer" checked>
<span class="flex h-[18px] w-[18px] items-center justify-center rounded-full border-2 border-sky-500 text-[10px] leading-none text-transparent transition peer-checked:bg-sky-500 peer-checked:text-white">★</span>
Featured
</label>
Choice note: CSS pseudo-elements keep icons out of the HTML, while Tailwind makes icon choices explicit. If you want clean markup and centralized control, CSS is great. If you want clarity and quick swaps, Tailwind is easier.
Cards turn a radio group into a small product picker or plan selector. The interaction is the same, but the label becomes a box with hover and checked states.
Vanilla CSS is well-suited for card radios because the styles are more complex and benefit from reusable class names.
<input type="radio" name="group" id="starter" class="custom-input" checked>
<label for="starter" class="card-label">
<span class="card-radio-mark"></span>
Starter
</label>
.card-label {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
transition: all 0.15s;
}
.card-radio-mark {
width: 16px;
height: 16px;
border: 2px solid #d1d5db;
border-radius: 50%;
position: relative;
}
.card-radio-mark::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: #2563eb;
transform: scale(0);
transition: transform 0.15s;
}
.custom-input:checked + .card-label {
background: #eff6ff;
border-color: #2563eb;
color: #1e40af;
}
.custom-input:checked + .card-label .card-radio-mark::after {
transform: scale(1);
}
Tailwind keeps card styling inside the label itself. The trade-off is a long class list, but it makes card variations quick to adjust per option.
<label class="block cursor-pointer">
<input type="radio" name="group" class="sr-only peer" checked>
<span class="relative flex items-center rounded-md border border-slate-200 bg-white px-3 py-2 pl-9 text-sm text-slate-600 transition peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 before:content-[''] before:absolute before:left-3 before:top-1/2 before:h-4 before:w-4 before:-translate-y-1/2 before:rounded-full before:border-2 before:border-slate-300 before:transition peer-checked:before:border-blue-500 after:content-[''] after:absolute after:left-[16px] after:top-1/2 after:h-2 after:w-2 after:-translate-y-1/2 after:rounded-full after:bg-blue-500 after:scale-0 after:transition peer-checked:after:scale-100">Starter</span>
</label>
Choice note: card radios feel more maintainable with semantic CSS classes, but Tailwind is excellent for rapid tweaks and A/B variants. If cards are a core pattern, vanilla is cleaner; if they are ad hoc, Tailwind is faster.
Animations help users notice state changes. The key is to keep the motion subtle and fast, then respect reduced-motion preferences.
In vanilla CSS you can tune easing curves precisely and apply a gentle scale animation to the inner dot. This is useful when you want a branded feel.
<input type="radio" name="group" id="bounce" class="custom-input" checked>
<label for="bounce" class="animated-label">
<span class="animated-radio-mark"></span>
Bounce
</label>
.animated-radio-mark {
width: 20px;
height: 20px;
border: 2px solid #a855f7;
border-radius: 50%;
}
.animated-radio-mark::after {
content: '';
width: 10px;
height: 10px;
background: #a855f7;
border-radius: 50%;
transform: scale(0);
transition: transform 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.4);
}
.custom-input:checked + .animated-label .animated-radio-mark::after {
transform: scale(1);
}
Tailwind keeps the animation readable by composing utilities: a ring on check plus a scaled dot. You get consistent motion tokens without extra CSS.
<label class="flex items-center gap-2 py-1 text-sm text-slate-600">
<input type="radio" name="group" class="sr-only peer" checked>
<span class="relative flex h-5 w-5 items-center justify-center rounded-full border-2 border-purple-500 transition peer-checked:ring-4 peer-checked:ring-purple-200 after:content-[''] after:block after:h-[10px] after:w-[10px] after:rounded-full after:bg-purple-500 after:scale-0 after:transition-transform after:duration-300 peer-checked:after:scale-100"></span>
Bounce
</label>
Choice note: CSS gives you precise easing control, while Tailwind gives you consistency and speed. If animation is part of your brand, vanilla is more expressive; if it is functional feedback, Tailwind is simpler.
Checkboxes map closely to radios, but the checkmark adds another layer. The same hidden-input pattern applies, with a square mark and a rotated tick.
With vanilla CSS you can craft the checkmark using borders and transforms. It is tiny but readable, and it keeps dependencies low.
<input type="checkbox" id="news" class="custom-input" checked>
<label for="news" class="basic-label">
<span class="basic-check-mark"></span>
Newsletter
</label>
.basic-check-mark {
width: 18px;
height: 18px;
border: 2px solid #d1d5db;
border-radius: 4px;
}
.basic-check-mark::after {
content: '';
width: 4px;
height: 8px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg) scale(0);
transition: transform 0.15s;
}
.custom-input:checked + .basic-label .basic-check-mark {
background: #6b7280;
border-color: #6b7280;
}
.custom-input:checked + .basic-label .basic-check-mark::after {
transform: rotate(45deg) scale(1);
}
Tailwind uses a similar square mark, but it is built with utility classes. The peer pattern keeps the HTML readable even with the check mark.
<label class="flex items-center gap-2 py-1 text-sm text-slate-600">
<input type="checkbox" class="sr-only peer" checked>
<span class="relative flex h-[18px] w-[18px] items-center justify-center rounded border-2 border-slate-300 transition peer-checked:border-slate-500 peer-checked:bg-slate-500 after:content-[''] after:block after:h-2 after:w-1 after:-mt-[2px] after:border-b-2 after:border-r-2 after:border-white after:rotate-45 after:scale-0 after:transition-transform peer-checked:after:scale-100"></span>
Newsletter
</label>
Choice note: vanilla keeps HTML lean and the checkmark easy to standardize. Tailwind is slightly longer but makes size and color tweaks painless.
Colored checkboxes are common in filters and status lists. The same currentColor trick works here and keeps colors aligned with text.
With vanilla CSS, one color class can style text, border, and fill. It reads as semantic color rather than a long utility list.
<input type="checkbox" id="urgent" class="custom-input" checked>
<label for="urgent" class="color-label rose">
<span class="color-check-mark"></span>
Urgent
</label>
.rose { color: #e11d48; }
.emerald { color: #059669; }
.color-check-mark {
width: 18px;
height: 18px;
border: 2px solid currentColor;
border-radius: 4px;
}
.color-check-mark::after {
content: '';
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg) scale(0);
}
.custom-input:checked + .color-label .color-check-mark {
background: currentColor;
}
.custom-input:checked + .color-label .color-check-mark::after {
transform: rotate(45deg) scale(1);
}
In Tailwind the color token is inline, which makes per-item overrides very fast. It also keeps the check mark and border in sync.
<label class="flex items-center gap-2 py-1 text-sm font-medium text-rose-600">
<input type="checkbox" class="sr-only peer" checked>
<span class="relative flex h-[18px] w-[18px] items-center justify-center rounded border-2 border-current transition peer-checked:border-current peer-checked:bg-current after:content-[''] after:block after:h-2 after:w-1 after:-mt-[2px] after:border-b-2 after:border-r-2 after:border-white after:rotate-45 after:scale-0 after:transition-transform peer-checked:after:scale-100"></span>
Urgent
</label>
Choice note: vanilla is great when you want semantic color tokens to live in one stylesheet. Tailwind excels when each checkbox has its own palette and you want to change it quickly.
Icons can communicate meaning faster than a plain tick. You can swap the check mark for a symbol or emoji to reinforce the label.
CSS lets you define icon variants as classes, which keeps the HTML minimal. This is great when you want a consistent icon size and alignment.
<input type="checkbox" id="approved" class="custom-input" checked>
<label for="approved" class="icon-label">
<span class="icon-check-mark thumb-icon"></span>
Approved
</label>
.thumb-icon { border-color: #8b5cf6; color: #8b5cf6; }
.thumb-icon::after { content: '✓'; }
.x-icon { border-color: #ef4444; color: #ef4444; }
.x-icon::after { content: '✕'; }
.custom-input:checked + .icon-label .icon-check-mark {
background: currentColor;
}
.custom-input:checked + .icon-label .icon-check-mark::after {
font-size: 11px;
color: white;
}
Tailwind uses explicit icon characters inside the mark. It is a bit more verbose, but the icon choice is visible in the HTML.
<label class="flex items-center gap-2 py-1 text-sm text-slate-600">
<input type="checkbox" class="sr-only peer" checked>
<span class="flex h-5 w-5 items-center justify-center rounded-[5px] border-2 border-violet-500 text-[10px] leading-none text-transparent transition peer-checked:bg-violet-500 peer-checked:text-white">✓</span>
Approved
</label>
Choice note: CSS makes icons reusable and consistent, while Tailwind is more explicit and easier to understand at a glance. Pick CSS for a design system, Tailwind for quick composition.
Card checkboxes are popular for settings or feature lists. They let users scan and toggle multiple options without the tiny target of a standard checkbox.
Vanilla CSS keeps the card styles in one place and uses a small check mark inside the card to indicate selection.
<input type="checkbox" id="dark" class="custom-input" checked>
<label for="dark" class="card-label">
<span class="card-check-mark"></span>
Dark mode
</label>
.card-check-mark {
width: 16px;
height: 16px;
border: 2px solid #d1d5db;
border-radius: 4px;
}
.card-check-mark::after {
content: '';
width: 4px;
height: 7px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg) scale(0);
}
.custom-input:checked + .card-label {
background: #eff6ff;
border-color: #2563eb;
color: #1e40af;
}
.custom-input:checked + .card-label .card-check-mark {
background: #2563eb;
border-color: #2563eb;
}
.custom-input:checked + .card-label .card-check-mark::after {
transform: rotate(45deg) scale(1);
}
Tailwind keeps the card look and the check mark in the same place, so you can change spacing or colors without editing CSS.
<label class="block cursor-pointer">
<input type="checkbox" class="sr-only peer" checked>
<span class="relative flex items-center rounded-md border border-slate-200 bg-white px-3 py-2 pl-9 text-sm text-slate-600 transition peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 before:content-[''] before:absolute before:left-3 before:top-1/2 before:h-4 before:w-4 before:-translate-y-1/2 before:rounded before:border-2 before:border-slate-300 before:transition peer-checked:before:border-blue-500 peer-checked:before:bg-blue-500 after:content-[''] after:absolute after:left-[18px] after:top-1/2 after:h-[7px] after:w-[4px] after:-translate-y-1/2 after:rotate-45 after:border-b-2 after:border-r-2 after:border-white after:scale-0 after:transition peer-checked:after:scale-100">Dark mode</span>
</label>
Choice note: card checkboxes are easier to maintain with semantic CSS if the pattern repeats often. Tailwind is strong for quick variants and faster prototyping.
Toggle switches are just styled checkboxes, but they signal an on/off state more clearly than a square box.
Vanilla CSS keeps the switch markup tiny and lets you fine-tune the knob size, track color, and transition timing.
<input type="checkbox" id="sound" class="custom-input" checked>
<label for="sound" class="toggle-label">
<span class="toggle-switch"></span>
Sound
</label>
.toggle-switch {
width: 40px;
height: 22px;
background: #d1d5db;
border-radius: 11px;
position: relative;
transition: background 0.2s;
}
.toggle-switch::after {
content: '';
position: absolute;
width: 18px;
height: 18px;
background: white;
border-radius: 50%;
top: 2px;
left: 2px;
transition: transform 0.2s;
}
.custom-input:checked + .toggle-label .toggle-switch {
background: #22c55e;
}
.custom-input:checked + .toggle-label .toggle-switch::after {
transform: translateX(18px);
}
Tailwind makes toggles easy to theme by swapping one or two utility classes. This is useful when you need different switch colors per feature.
<label class="flex items-center gap-3 py-1 text-sm text-slate-600">
<input type="checkbox" class="sr-only peer" checked>
<span class="relative h-[22px] w-[40px] rounded-full bg-slate-300 transition peer-checked:bg-emerald-500 after:content-[''] after:absolute after:left-[2px] after:top-[2px] after:h-[18px] after:w-[18px] after:rounded-full after:bg-white after:transition peer-checked:after:translate-x-[18px]"></span>
Sound
</label>
Choice note: toggles are simple enough for either approach. Vanilla is compact and reusable; Tailwind is flexible when you need multiple color variants across a page.
Grouping matters for readability. Most users scan groups vertically, but inline groups are useful for compact forms or filters.
Vanilla CSS lets you define a small layout utility such as a grid stack or an inline row. This is often enough without a full layout system.
<div class="group-stack">
<label class="basic-label">...</label>
<label class="basic-label">...</label>
</div>
<div class="group-inline">
<label class="basic-label">...</label>
<label class="basic-label">...</label>
</div>
.group-stack {
display: grid;
gap: 8px;
}
.group-inline {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
Tailwind makes layout experiments fast. You can switch from stacked to inline with a couple of classes, which is useful for responsive tweaks.
<div class="flex flex-col gap-2">...</div>
<div class="flex flex-wrap gap-3">...</div>
Choice note: vanilla works well when layout needs are simple and consistent. Tailwind is ideal when you want to adjust spacing and responsiveness per group without new CSS.
Some forms need compact controls, others need large tap targets. Size variants make your inputs flexible across different contexts.
Vanilla CSS can scale size with a pair of modifier classes. Because your mark is a span, changing its width and height is straightforward.
<div class="size-sm">
<label class="basic-label">...</label>
</div>
<div class="size-lg">
<label class="basic-label">...</label>
</div>
.size-sm { font-size: 0.75rem; }
.size-sm .basic-radio-mark { width: 14px; height: 14px; }
.size-lg { font-size: 1rem; }
.size-lg .basic-radio-mark { width: 22px; height: 22px; }
Tailwind makes sizing a per-instance decision. You can dial in the size quickly by adjusting the height and width utilities.
<span class="h-3 w-3 ..."></span>
<span class="h-6 w-6 ..."></span>
Choice note: vanilla makes it easy to define named size scales. Tailwind lets you pick a size ad hoc. Use vanilla for strict design systems, Tailwind for flexible layouts.
Custom inputs must remain keyboard friendly. Focus rings and reduced-motion support make the controls feel polished and accessible.
With vanilla CSS you can add :focus-visible styles and respect users who prefer reduced motion. It keeps the accessibility rules in one place.
<input type="radio" name="group" id="focus" class="custom-input">
<label for="focus" class="basic-label">
<span class="basic-radio-mark"></span>
Tab to me
</label>
.custom-input:focus-visible + .basic-label .basic-radio-mark {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.35);
border-color: #3b82f6;
}
@media (prefers-reduced-motion: reduce) {
.animated-radio-mark,
.animated-radio-mark::after {
transition: none;
}
}
Tailwind offers focus and reduced-motion utilities, which helps keep accessibility in the markup. This is especially handy for teams that enforce utility-first conventions.
<span class="... peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 motion-reduce:transition-none"></span>
Choice note: CSS centralizes accessibility rules, while Tailwind keeps them close to the component. Both are valid; pick the one that matches how your team enforces accessibility standards.
Disabled and error states are often forgotten, but they matter in real forms. They also test how well your approach scales beyond the happy path.
Vanilla CSS can treat these as modifier classes, which keeps the HTML expressive and avoids duplicating styles across components.
<div class="is-disabled">
<label class="basic-label">...</label>
</div>
<div class="is-error">
<label class="basic-label">...</label>
</div>
.is-disabled { opacity: 0.5; pointer-events: none; }
.is-error { color: #b91c1c; }
.is-error .basic-check-mark { border-color: #ef4444; }
Tailwind provides disabled: and color utilities to keep state styles close to the element. It is explicit but easy to scan in reviews.
<label class="flex items-center gap-2 text-sm text-red-600">
<input type="checkbox" class="sr-only peer">
<span class="border-2 border-red-500 peer-checked:bg-red-500"></span>
</label>
Choice note: CSS modifiers are a clean mental model for states. Tailwind keeps states explicit and closer to the element, which can help catch errors during review.
At this point you have seen the same patterns implemented two ways. The decision is less about what is possible and more about which workflow keeps your team moving.
Vanilla CSS is great when you want semantic class names, a small HTML footprint, and the ability to refactor styles without touching markup. Agents can generate clean class-based systems quickly, and humans often find them easier to scan.
<input type="checkbox" id="choice" class="custom-input" checked>
<label for="choice" class="basic-label">
<span class="basic-check-mark"></span>
Vanilla: compact markup
</label>
.basic-label { display: flex; gap: 8px; }
.basic-check-mark { border: 2px solid #d1d5db; }
Tailwind is strong when your team wants a shared design language, quick iteration, and fewer CSS files. Its advantages include consistent spacing and color scales, easy responsive variants, and straightforward pruning of unused styles in a build. Agents can draft accurate utilities from plain language, and the class list doubles as documentation of spacing, color, and state.
<label class="flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" class="sr-only peer" checked>
<span class="relative flex h-[18px] w-[18px] items-center justify-center rounded border-2 border-slate-300 transition peer-checked:border-slate-500 peer-checked:bg-slate-500 after:content-[''] after:block after:h-2 after:w-1 after:-mt-[2px] after:border-b-2 after:border-r-2 after:border-white after:rotate-45 after:scale-0 after:transition-transform peer-checked:after:scale-100"></span>
Tailwind: explicit utilities
</label>
So, do we still need frameworks like Tailwind now that agents can write CSS well? Often yes. Agents can generate either approach, but Tailwind still helps teams align on tokens, reduce CSS drift, and speed up review because the styling intent is visible in markup. Vanilla CSS remains excellent for compact HTML, long-lived design systems, and situations where you want to decouple structure from style. Tailwind samples are verbose but self-documenting, while vanilla samples are concise but ask readers to jump to CSS to understand intent. As a rule of thumb: if your team prioritizes a consistent utility language and rapid iteration, Tailwind wins; if you prioritize reusable semantic classes and minimal HTML, vanilla wins. For humans and agents alike, the easier approach is the one that matches your team workflow and the scale of your codebase.