9 CSS Checkbox Radio Animations — Copy-Paste
A CSS checkbox radio keeps the browser's real input in place and lets :checked alone redraw whatever sits next to it, one line, copy-paste onto a form that
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Checkmark stroke draw
- 02 Bouncy check
- 03 Radio fill animation
- 04 Switch-style checkbox
- 05 Card-select style
- 06 Todo strikethrough
- 07 Color swatch pick
- 08 Star-rate input
- 09 Chip toggle
The order below tracks how many boxes a click actually touches. 01, 02, 06, and 09 each toggle one independent checkbox on and off. 03, 05, 07, and 08 are radios, where picking one always clears whichever option lit up before it. 04 sits between the two: it's wired as a checkbox, but its knob-and-track shape borrows the look everyone reads as a switch.
01Checkmark stroke draw
Checking the box drains an SVG path's stroke-dashoffset from 20 down to 0, so the checkmark looks hand-drawn from its bottom-left point up to its tip rather than just appearing. A terms-agreement checkbox or a single to-do item both benefit from just over half a second of "I saw this happen."
.box {
border: 2px solid rgba($subject-cream, .55);
transition: background $duration $easing, border-color $duration $easing;
}
.sr-input:checked ~ .box { background: $color; border-color: $color; }
.box__path {
stroke-dasharray: 20;
stroke-dashoffset: 20;
transition: stroke-dashoffset $duration $easing;
}
.sr-input:checked ~ .box .box__path { stroke-dashoffset: 0; }
02Bouncy check
The instant the box is checked, its icon scales from 0 up to 1.3 and settles back at 1 through a single @keyframes bounce, instead of the plain stroke draw item 01 uses. Multi-select settings rows and an add-to-cart checkbox both want that extra snap since the click is the whole point of the interaction.
.sr-input:checked ~ .box .box__svg {
animation: check-pop $duration $easing forwards;
}
@keyframes check-pop {
0% { transform: scale(0); opacity: 0; }
55% { transform: scale(1.3); opacity: 1; }
75% { transform: scale(.9); }
100% { transform: scale(1); opacity: 1; }
}
03Radio fill animation
Three radios share one name, so picking a second option flips its sibling's :checked off in the same instant. The same rule that grows the new dot from scale(0) to scale(1) shrinks the old one back down. Payment-method pickers and shipping-option lists rely on exactly one dot ever being visible.
.radio__dot::after {
content: "";
width: 12px; height: 12px;
border-radius: 50%;
background: $color;
transform: scale(0);
transition: transform $duration $easing;
}
.sr-input:checked ~ .radio__dot::after { transform: scale(1); }
04Switch-style checkbox
This is a checkbox pretending to be a switch: the knob never moves, and a clip-path: circle() layer underneath it grows from a 0% dot to a 75% circle to fill the track with color. Two small icons inside the knob cross-fade through opacity and scale at the same time, so a moon or sun swaps in without the knob sliding an inch.
.fill {
position: absolute; inset: 0;
background: $color;
clip-path: circle(0% at 50% 50%);
transition: clip-path $duration $easing;
}
.sr-input:checked ~ .track .fill {
clip-path: circle(75% at 50% 50%);
}
05Card-select style
:has(.sr-input:checked) reaches up from a buried radio to style the whole .card wrapper around it, lighting the border and popping a check badge into view with no shared class or JavaScript in between. Pricing-tier cards and shipping-method cards both need the entire card to read as selected, not just a small dot in the corner.
.card:has(.sr-input:checked) {
border-color: $color;
background: rgba($subject-cream, .14);
}
.card:has(.sr-input:checked) .card__badge {
border-color: $color;
background: $color;
}
.card:has(.sr-input:checked) .card__badge svg {
opacity: 1; transform: scale(1);
}
06Todo strikethrough
Checking a to-do draws a scaleX(0) to scaleX(1) line across the label from an ::after pseudo-element while the text itself fades to 45% opacity. The strike and the fade run on the same clock instead of one snapping ahead of the other. Any checklist or task list uses the pairing to mark an item done without deleting the row outright.
.strike {
position: absolute; inset-inline: 0; top: 50%;
height: 2px; background: currentColor;
transform: scaleX(0);
transform-origin: left;
transition: transform $duration $easing;
}
.sr-input:checked ~ .todo__text .strike { transform: scaleX(1); }
.sr-input:checked ~ .todo__text .label { opacity: .45; }
07Color swatch pick
Each swatch is its own label, and :has(.sr-input:checked) grows a ring around whichever one holds the checked radio, from scale(0) to scale(1) with a matching opacity fade. Product color pickers and theme selectors both skip adding a border to every swatch up front, since the ring only exists once something is actually chosen.
.swatch::after {
content: "";
position: absolute; inset: 0;
border-radius: 50%;
border: 2px solid $color;
transform: scale(0);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.swatch:has(.sr-input:checked)::after { transform: scale(1); opacity: 1; }
08Star-rate input
The five radios sit in the markup 5 down to 1, and flex-direction: row-reverse flips that back to reading 1 through 5 on screen. That's the trick that lets one sibling selector light every star up to and including the checked one, not just the star itself. Review forms and satisfaction surveys use the same five inputs a <select> would need, minus the dropdown.
.stars { display: flex; flex-direction: row-reverse; gap: 6px; }
.sr-input:checked ~ .star .star__icon,
.sr-input:checked ~ .star ~ .star .star__icon {
background: $color;
}
09Chip toggle
Pressing a chip fills its background from the center out via scaleX(0) to scaleX(1), and a separate :active rule scales the whole chip to 0.96 for the length of the press. The fill and the squeeze never fight over the same transform. Tag pickers and filter bars lean on it since several chips can sit checked at once.
.fill {
position: absolute; inset: 0;
background: $color; z-index: 0;
transform: scaleX(0);
transform-origin: center;
transition: transform $duration $easing;
}
.sr-input:checked ~ .fill { transform: scaleX(1); }
.chip:active { transform: scale(.96); }
Where it breaks — hiding the input isn't the same as removing it
Every one of the nine keeps a genuine <input type="checkbox"> or <input type="radio"> in the markup, hidden with position: absolute; width: 1px; height: 1px; clip: rect(0,0,0,0); rather than display: none or visibility: hidden. Swap in either of those two and the input drops out of the tab order entirely. A mouse still clicks the drawn shape next to it, but a keyboard user can no longer reach the control at all, since there's nothing left to focus. Items 05 and 07 lean on :has() to style a parent from a checked radio buried inside it, and that selector only reaches back to Chrome and Edge 105, Safari 15.4, and Firefox 121. A browser below those versions still lets every card and swatch get clicked and checked, but it just leaves the border and the badge exactly as they were, so the choice registers without ever looking chosen. Item 08 takes the input-hiding trick one step further and reorders the radios themselves — 5, 4, 3, 2, 1 in the markup, flipped visually by row-reverse. That reorder exists purely so one ~ sibling selector can light every star up to the pick. Put the radios back in reading order, and that same selector lights the wrong half of the row. The password below opens a zip where every demo's autoplay script — the four to six lines each index.html uses to flip checked on a loop — has already been stripped back out. What's left is the click-driven version only: 8zg24hjv, typed exactly as it reads here.
Accessibility
Because the real input stays in the document rather than getting deleted, a screen reader announces "checkbox, checked" or "radio button, 2 of 3" the same way it would for an unstyled form. Tab, Space, and the arrow keys move and flip the value without any extra script. All nine carry their own :focus-visible ring on the shape sitting next to the input — item 06's ring sits on its checkbox box, not on the strikethrough itself. Item 08 is also the one built differently under the hood — its label sits right after its own input in the markup. That's why the rule reaches it with + rather than the ~ every other item uses:
.sr-input:focus-visible + .star .star__icon {
box-shadow: 0 0 0 3px rgba($subject-blue, .35);
}
prefers-reduced-motion: reduce drops every transition on this page to a 1ms duration, so a checked box or a picked radio lands on its final look immediately instead of animating there. Item 02 is the one exception — its bounce runs on a @keyframes animation rather than a transition. Reduced motion switches the animation off outright and jumps straight to the checked end state instead of just speeding it up. Either way, nothing about the checked state itself changes, only how fast — or whether — it arrives:
@media (prefers-reduced-motion: reduce) {
.box, .box__path { transition-duration: 1ms !important; }
}
MDN's guide to :focus-visible covers when browsers draw that ring instead of the default outline. The rest of this site's form and button patterns sit in the CSS category, and what's free to read here versus what's zipped up is on the about page.
FAQ
Should I use a checkbox or a radio for a custom control like this?
Reach for a checkbox when one option flips on and off by itself, and a radio when picking one option has to clear whichever one was picked before. Item 04 is wired as a checkbox even though it looks like a switch, so several of them can sit on at once — a real radio group could never do that.
Do I need a <label>, or is aria-label enough?
aria-label alone covers a swatch or a star where the shape already says what it means. But anything carrying its own sentence — item 01's terms line, for instance — reads better wrapped in a <label>, since that pulls the whole sentence into the clickable area instead of only the small box.
The :checked selector doesn't seem to fire in my React version — why not?
The compiled CSS Module class still ships a working :checked rule. What's missing is usually the checked prop itself, since React needs useState and onChange wired up before the input becomes a controlled one at all. The react/ folder in the zip has all nine built that way already, ready to copy the pattern from.