GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS Custom Checkbox: 9 Interaction Patterns

CSS Custom Checkbox: One of nine draws its checkmark length to full on check, one bounces the box size then settles on toggle, and one flips every child

Auto-plays · click a tile to jump to its section · all nine in one zip

None of the nine reaches out to a server, writes to localStorage, or calls Math.random anywhere in its script — each is a small, self-contained checkbox built around one state value (checked, indeterminate, or a selected count) and a short list of constants that decide exactly how a change in that value looks on screen. Because the scope stays this narrow, naming which value actually moves — a checked flip, a mixed-selection ratio, or a count — is enough to tell which of these nine css custom checkbox examples fits a given form before any of the code gets opened.

Anatomy of nine patterns — which value changes and when

Eight of the nine call requestAnimationFrame at least once for their autoplay loop, but only seven of them — items 01, 03, 04, 06, 07, 08, and 09 — actually recompute a real value inside that loop every frame: item 01's currentOffset() eases a stroke-dashoffset from wherever it currently sits toward zero or the path's full getTotalLength(), item 03's popScale() multiplies a Math.exp-based SPRING_ZETA decay by a POP_FREQ cosine so the box overshoots by SCALE_OVERSHOOT before settling, item 04's currentPos() eases a digit strip toward -count * ROW_H, item 06's loop reads a clip-path: circle() radius from easeOutCubic(t) * diag against the card's own measured diagonal, item 07's shakeOffset() runs a damped Math.sin wave at a frequency tuned to five oscillations across SHAKE_MS, item 08 eases a shackle's rotation toward UNLOCK_ANGLE around a fixed pivot, and item 09 runs that same draw math on its normal panel while its reduced panel jumps straight to target. Item 02 never calls requestAnimationFrame at all — its autoplay runs on a plain setInterval, and the only computed value is a single background alpha, MIX_ALPHA + (1 - MIX_ALPHA) * ratio, set once per change and then left to the browser's own CSS transition. Item 05 does call requestAnimationFrame, but only to drive its autoplay clock; the staircase itself comes from a transition-delay of index * STAGGER_MS set once per child box, so a native CSS transition — not a per-frame loop — decides when each child becomes visible. None of the nine calls Math.random anywhere, and anyone chasing down a css custom checkbox codepen to study before writing their own will find the same nine scripts here, each one the whole file rather than a trimmed snippet. Every listener answers a real interaction: six items (01, 02, 03, 04, 05, and 09) listen for a plain change on their own <input type="checkbox">, item 06 pairs that same change with a resize listener that re-measures its card, item 07 pairs change with a click on its submit button, and item 08 is the outlier, driven only by a click on a separate lock toggle rather than the checkbox itself. Under prefers-reduced-motion: reduce, six items — 01, 03, 04, 06, 07, and 08 — keep the identical calculation and just collapse their duration constant to 1, skipping the in-between frames without skipping the math; items 02 and 05 instead let their scss set transition: none, since their motion was already CSS-driven; and item 09 exists specifically to put a normal and a reduced panel side by side under one shared checked value.

Code per pattern

01Check-draw toggle — when the checked value turns on, the checkmark's line-length value draws from zero to full in order

The instant the checked value turns on, the checkmark's line-length value draws from zero to full, and reverses when it turns off. A terms-agreement item or a completion mark on a to-do list suits this pattern, since getTotalLength() measures the checkmark path's real length instead of guessing a pixel count, so stroke-dashoffset always lands on a whole stroke no matter how the icon is resized.

Check-draw toggle — when the checked value turns on, the checkmark's line-length value draws from zero to full in order
Open in a new tab

01 · Check-draw toggle — when the checked value turns on, the checkmark's line-length value draws from zero to full in order — Measured area 0.442% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

DRAW_MSstroke-dashoffsetgetTotalLength
  // calc layer: measure the SVG path's real length with getTotalLength() to build stroke-dasharray — no guessed px
  var LEN = path.getTotalLength();
  path.style.strokeDasharray = String(LEN);

  var isDemo = true;
  var drawing = false, drawStart = 0, drawFrom = LEN, drawTo = LEN;
  path.style.setProperty('stroke-dashoffset', String(LEN));

  function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }

  function currentOffset(ts) {
    if (!drawing) return drawTo;
    var t = Math.min(1, (ts - drawStart) / DRAW_MS);
    return drawFrom + (drawTo - drawFrom) * easeOutCubic(t);
  }

  function setChecked(next, ts) {
    input.checked = next;
    box.classList.toggle('cbd__box--on', next);
    drawFrom = currentOffset(ts);
    drawTo = next ? 0 : LEN;
    drawStart = ts;
    drawing = true;
  }

What the numbers say

Item 01 fills 0.44% of the frame cumulatively at a 73.0 average intensity while moving in 14 of 23 sampled frames, because currentOffset() restarts the draw from wherever the stroke currently sits — drawFrom = currentOffset(ts) — rather than from zero, so toggling mid-draw still lands on a continuous line instead of snapping.

02Indeterminate-dash toggle — when only some child items are selected, the parent box's mark value switches to a dash shape

While the child-selection count value sits between zero and all, the parent box's mark value becomes a dash instead of checked or empty. A file list with group selection or a settings item with sub-options suits this pattern, since paintParent() sets the native indeterminate property directly from the checked count rather than only toggling a CSS class, so a screen reader still reports the mixed state correctly.

Indeterminate-dash toggle — when only some child items are selected, the parent box's mark value switches to a dash shape
Open in a new tab

02 · Indeterminate-dash toggle — when only some child items are selected, the parent box's mark value switches to a dash shape — Measured area 1.703% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

indeterminateMIX_ALPHAbackground
  function paintChildren() {
    children.forEach(function (c, i) { childBoxes[i].classList.toggle('cbi__box--on', c.checked); });
  }

  function paintParent() {
    paintChildren();
    var checkedCount = children.filter(function (c) { return c.checked; }).length;
    var ratio = checkedCount / children.length; // calc layer: the selection-ratio value
    var all = ratio === 1, none = ratio === 0;
    parent.checked = all;
    parent.indeterminate = !all && !none;
    parentBox.classList.toggle('cbi__box--on', all);
    parentBox.classList.toggle('cbi__box--mixed', !all && !none);
    // calc layer: project the selection ratio straight onto the background alpha (MIX_ALPHA as the floor, the rest of the range filled by ratio)
    var alpha = none ? 0 : (MIX_ALPHA + (1 - MIX_ALPHA) * ratio);
    parentBox.style.setProperty('--cbi-alpha', alpha.toFixed(3));
  }

What the numbers say

Item 02 fills 1.70% of the frame cumulatively at a 48.4 average intensity while moving in 15 of 23 sampled frames, because the fill color itself is a computed value — MIX_ALPHA + (1 - MIX_ALPHA) * ratio — so the parent box's background alpha tracks the exact selected ratio instead of jumping between two fixed colors.

03Spring-pop toggle — the instant the checked value flips, the box's size value overshoots its target then settles with a bounce

The moment the checked value flips, the box's size value overshoots its target slightly, bounces two or three times, then settles. A selection item that wants a vivid check reaction, or a gamified checklist, suits this pattern, since popScale() multiplies a damped cosine by SCALE_OVERSHOOT so the box always bounces past scale 1 by the same ratio before settling back.

Spring-pop toggle — the instant the checked value flips, the box's size value overshoots its target then settles with a bounce
Open in a new tab

03 · Spring-pop toggle — the instant the checked value flips, the box's size value overshoots its target then settles with a bounce — Measured area 0.544% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPRING_ZETASCALE_OVERSHOOTtransform
  function popScale(t) {
    // calc layer: damped cosine oscillation — jumps up by SCALE_OVERSHOOT at t=0, then settles to 1
    if (t < 0 || t > POP_MS) return 1;
    var amp = Math.exp(-SPRING_ZETA * (t / POP_MS));
    return 1 + amp * SCALE_OVERSHOOT * Math.cos(POP_FREQ * t);
  }

  function setChecked(next, ts) {
    input.checked = next;
    box.classList.toggle('cbs__box--on', next);
    popStart = ts;
  }

What the numbers say

Item 03 fills 0.54% of the frame cumulatively at an 82.7 average intensity while moving in 11 of 23 sampled frames, because popStart resets to the current timestamp on every toggle regardless of where the previous bounce was, so a fast double-click restarts the overshoot from full amplitude instead of continuing the decay.

04Count-badge roll toggle — each time the selected-count value rises or falls, the badge digit's position value rolls up or down

Each time the selected-count value goes up or down by one, the badge digit's vertical-position value rolls toward the next number. A list for picking multiple items, or a filter that shows the selection count live, suits this pattern, since the target offset is simply -count * ROW_H, so the digit strip's position is always a direct multiple of the row height instead of an interpolated guess.

Count-badge roll toggle — each time the selected-count value rises or falls, the badge digit's position value rolls up or down
Open in a new tab

04 · Count-badge roll toggle — each time the selected-count value rises or falls, the badge digit's position value rolls up or down — Measured area 0.765% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

ROLL_MStranslateYclip-path
  function currentPos(ts) {
    if (!rolling) return to;
    var t = Math.min(1, (ts - rollStart) / ROLL_MS);
    return from + (to - from) * easeOutCubic(t);
  }

  function paint(ts) {
    inputs.forEach(function (inp, i) { boxes[i].classList.toggle('cbc__box--on', inp.checked); });
    var count = inputs.filter(function (i) { return i.checked; }).length;
    var target = -count * ROW_H;
    if (target !== to) {
      from = currentPos(ts);
      to = target;
      rollStart = ts;
      rolling = true;
    }
  }

What the numbers say

Item 04 fills 0.77% of the frame cumulatively at an 83.4 average intensity while moving in 13 of 23 sampled frames, because currentPos() also restarts its easeOutCubic interpolation from wherever the strip currently sits, so counting two items in quick succession still rolls smoothly past the skipped digit.

05Select-all cascade toggle — when the parent box's select-all value turns on, each child box's checked value flips in order with a short stagger

The instant the parent select-all value turns on, the child boxes' checked values flip one after another from the top with a brief stagger. A table with a select-all control, or an item list before a bulk action, suits this pattern, since each child box gets its own transition-delay of index * STAGGER_MS set once, so the browser's own CSS transition — not a per-frame loop — draws the staircase timing.

Select-all cascade toggle — when the parent box's select-all value turns on, each child box's checked value flips in order with a short stagger
Open in a new tab

05 · Select-all cascade toggle — when the parent box's select-all value turns on, each child box's checked value flips in order with a short stagger — Measured area 2.429% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

STAGGER_MSindeterminatetransition-delay
  // calc layer: build each child box's transition-delay as index × STAGGER_MS — the value itself
  // changes all at once, but the moment it becomes visible gets a staircase order
  childBoxes.forEach(function (b, i) {
    b.style.setProperty('transition-delay', (i * STAGGER_MS) + 'ms');
  });

  function paintParent() {
    var count = childInputs.filter(function (c) { return c.checked; }).length;
    var all = count === childInputs.length, none = count === 0;
    parent.checked = all;
    parent.indeterminate = !all && !none;
    parentBox.classList.toggle('cba__box--on', all);
    parentBox.classList.toggle('cba__box--mixed', !all && !none);
  }

  function paintChildren() {
    childInputs.forEach(function (c, i) { childBoxes[i].classList.toggle('cba__box--on', c.checked); });
  }

What the numbers say

Item 05 fills 2.43% of the frame cumulatively at a 50.6 average intensity while moving in 20 of 23 sampled frames, because all four checked values change at the same instant in the DOM while transition-delay only changes when each one is allowed to be seen, so the value and its visible timing are two separate things.

06Color-fill-wipe toggle — when the checked value turns on, the box's fill value spreads from one corner to the opposite one

The moment the checked value turns on, the box's fill value starts at one corner and spreads outward to the opposite corner. A selection item wanting to emphasize an accent color, or a card-style selection list, suits this pattern, since measure() reads the card's real getBoundingClientRect() diagonal so the clip-path: circle() radius always reaches the far corner exactly, on any card size.

Color-fill-wipe toggle — when the checked value turns on, the box's fill value spreads from one corner to the opposite one
Open in a new tab

06 · Color-fill-wipe toggle — when the checked value turns on, the box's fill value spreads from one corner to the opposite one — Measured area 7.924% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

WIPE_MSclip-pathbackground
  function measure() {
    // calc layer: the card's real measured diagonal — the radius that lands the fill circle exactly on the far corner
    var r = card.getBoundingClientRect();
    diag = Math.sqrt(r.width * r.width + r.height * r.height);
  }

  function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }

  function human() {
    if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
  }

  function setChecked(next, ts) {
    input.checked = next;
    wipeDir = next ? 1 : -1;
    wipeStart = ts;
    wiping = true;
  }

What the numbers say

Item 06 fills 7.92% of the frame cumulatively at a 199.6 average intensity while moving in 8 of 23 sampled frames — among the fewest moved frames of the nine but by far the strongest per-frame change — because each moved frame redraws a large filled circle instead of nudging a small box.

07Required-error shake toggle — when a submit attempt hits an empty required box, its horizontal-position value shakes briefly side to side

The instant a submit-attempt value fires, the empty required box's horizontal-position value shakes briefly and its border-color value turns red. A signup form with a required agreement, or a checked item that needs confirmation before submit, suits this pattern, since shakeOffset() computes a damped sine wave whose frequency is set so the shake completes exactly five oscillations within SHAKE_MS, so the warning always reads as a shake and never a single jolt.

Required-error shake toggle — when a submit attempt hits an empty required box, its horizontal-position value shakes briefly side to side
Open in a new tab

07 · Required-error shake toggle — when a submit attempt hits an empty required box, its horizontal-position value shakes briefly side to side — Measured area 9.7% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

SHAKE_MStranslateXborder-color
  function shakeOffset(t) {
    // calc layer: damped sine oscillation — computes the shake value's amplitude shrinking directly over time
    var amp = Math.exp(-SHAKE_ZETA * (t / SHAKE_MS));
    return SHAKE_AMP * amp * Math.sin(SHAKE_FREQ * t);
  }

  function triggerError() {
    row.classList.add('cbe__row--error');
    hint.classList.add('cbe__hint--error');
    shakeStart = performance.now();
    shaking = true;
  }

  function clearError() {
    row.classList.remove('cbe__row--error');
    hint.classList.remove('cbe__hint--error');
  }

What the numbers say

Item 07 fills 9.70% of the frame cumulatively at a 60.3 average intensity while moving in 16 of 23 sampled frames, because the same submit click handler that starts the shake also flips to clearError() the moment the box's checked value turns true, so the warning cannot outlive the fix.

08Disabled-dim-lock toggle — when the disabled value turns on, the box's brightness value drops and the checked value stops responding

The instant the disabled value turns on, the box's brightness value drops, and clicks no longer change the checked value after that. An option whose condition isn't met yet, or a choice that depends on another item, suits this pattern, since a separate lock toggle — not the checkbox itself — drives input.disabled, so the checkbox's own change event is never asked to fire while it's locked.

Disabled-dim-lock toggle — when the disabled value turns on, the box's brightness value drops and the checked value stops responding
Open in a new tab

08 · Disabled-dim-lock toggle — when the disabled value turns on, the box's brightness value drops and the checked value stops responding — Measured area 1.173% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

disabledfilterpointer-events
  function currentAngle(ts) {
    if (!animating) return angleTo;
    var t = Math.min(1, (ts - animStart) / LOCK_MS);
    return angleFrom + (angleTo - angleFrom) * easeOutCubic(t);
  }

  function apply(nextLocked, ts) {
    locked = nextLocked;
    input.disabled = locked;
    row.classList.toggle('cbl__row--locked', locked);
    toggle.setAttribute('aria-pressed', String(locked));
    toggleText.textContent = locked ? 'Unlock' : 'Lock';
    angleFrom = currentAngle(ts);
    angleTo = locked ? 0 : UNLOCK_ANGLE;
    animStart = ts;
    animating = true;
  }

What the numbers say

Item 08 fills 1.17% of the frame cumulatively at a 44.1 average intensity while moving in 7 of 23 sampled frames — the fewest moved frames of the nine — because the shackle's rotation is the only thing animating, pivoting around a fixed (8, 11) point so the lock body itself never has to redraw.

09Reduced-motion comparison toggle — the same checked-value change draws its line-length value on the normal panel, and jumps straight to target with no in-between step on the reduced panel

When the same checked value flips, the normal panel's line-length value draws in, while the reduced panel jumps straight to the target value with no in-between step. A screen for users with the accessibility setting on, or a form that wants fewer animations, suits this pattern, since both panels run the same DURATION_MS calculation and only the reduced panel's own change handler skips straight to checked ? 0 : LEN instead of animating toward it.

Reduced-motion comparison toggle — the same checked-value change draws its line-length value on the normal panel, and jumps straight to target with no in-between step on the reduced panel
Open in a new tab

09 · Reduced-motion comparison toggle — the same checked-value change draws its line-length value on the normal panel, and jumps straight to target with no in-between step on the reduced panel — Measured area 0.617% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

DURATION_MSCYCLE_MSmatchMedia
  function currentOffset(ts) {
    if (!drawing) return drawTo;
    var t = Math.min(1, (ts - drawStart) / DURATION_MS);
    return drawFrom + (drawTo - drawFrom) * easeOutCubic(t);
  }

  function setState(next, ts) {
    // normal panel: interpolates draw-on over DURATION_MS against the measured length (the real path when matchMedia is false)
    normalInput.checked = next;
    normalBox.classList.toggle('cbr__box--on', next);
    drawFrom = currentOffset(ts);
    drawTo = next ? 0 : LEN;
    drawStart = ts;
    drawing = true;

    // reduced panel: same logic branch as the matchMedia result, jumps straight to the target state with no in-between frame
    reducedInput.checked = next;
    reducedBox.classList.toggle('cbr__box--on', next);
  }

What the numbers say

Item 09 fills 0.62% of the frame cumulatively at a 96.7 average intensity while moving in 11 of 23 sampled frames — read as a component pair rather than a scene, since the two panels exist to be compared side by side rather than to fill the frame.

Where values change shape and color — checked, indeterminate, and count basis

A css customize checkbox color task usually means a single hex swap, but three of the nine push further than that. Item 06's clip-path: circle() ties color directly to a measured distance: the fill's radius is easeOutCubic(t) * diag, where diag comes from the card's own getBoundingClientRect() diagonal, so the checked color always finishes exactly at the far corner regardless of how wide the card is. Item 02's background alpha is the clearest color-from-ratio case here: MIX_ALPHA + (1 - MIX_ALPHA) * ratio keeps a floor of 22% opacity even at zero children checked, then fills the rest of the range as the ratio climbs toward one, so the parent box's tint always matches how much of the group is actually selected. Item 04's digit strip, by contrast, changes shape and position rather than color: the count value maps straight onto -count * ROW_H, so the badge is never told which number to display — it is only told where to sit, and the row height decides which digit shows through the window.

That checked-versus-indeterminate-versus-count split matters most once a pattern like this leaves this page for a real settings screen. 9 CSS Toggle Switches — One Line, Copy-Paste computes its handle position along a single track instead of a checked/indeterminate/count triple, since a switch only ever has two states to place. CSS Toggle Switch: 9 Realistic Switches to Paste shares that same handle-position axis but layers in shadow and highlight values to look physically pressed, still without a third state. 9 Form Validation UI Patterns — As You Type sits closest to item 07 here, except its display axis is keystroke-triggered validity rather than a submit-time checked value. Copy the ukycae87 out of the zip once and it unlocks every one of these nine patterns at the same time, so there's no need to reopen it per item.

Where it breaks — rapid toggling, required fields, reduced motion

Nine patterns that each compute their own timing also fail in nine different ways once real users stop clicking one box at a time.

Rapid toggling before the bounce or the draw settles

Item 03's popStart resets to the current timestamp on every change event no matter where the previous bounce was, so double-clicking fast enough restarts the overshoot from full amplitude and produces a visible snap instead of a smooth continuation. Item 01 and item 04 avoid exactly this by reading currentOffset()/currentPos() before setting a new target, so their equivalent rapid toggle continues from wherever the line or the digit strip currently sits instead of jumping back to zero.

A required box that's also disabled

Item 07's submit handler checks input.checked directly and never calls the browser's own reportValidity(), so wiring this shake into a real form still needs that native call for the browser's own validation bubble to show up alongside it. Pairing item 07's required field with item 08's lock is worth thinking through too: a disabled checkbox is skipped by native constraint validation entirely, so a required-and-locked box could silently pass a form that should have blocked it.

Reduced motion, two different ways

Six of the nine — items 01, 03, 04, 06, 07, and 08 — keep the exact same calculation under prefers-reduced-motion: reduce and just set their duration constant to 1, so the math still runs but skips every frame in between. Items 02 and 05 take the opposite route, since their motion was already the browser's own CSS transition rather than a JS loop, so their scss simply sets transition: none for the same effect. Item 09 exists to put both approaches side by side in one demo, one panel animating and the other jumping, driven by the same checked value.

Three variants and accessibility

Raising item 03's overshoot from 0.34 to 0.55 makes the box balloon well past its target size before settling, giving the check a more playful snap. Cutting item 07's shake amplitude from 8 to 3px makes the empty required box's warning far more subdued, keeping the screen calmer. Stretching item 06's wipe time from 420 to 900ms makes the fill's spread from the corner clearly visible, giving the check moment more weight.

Variant What changes What it doesn't touch
Spring overshoot ratio SCALE_OVERSHOOT 0.34 → 0.55 The popScale() formula, POP_MS, the checked value itself
Required-error shake amplitude SHAKE_AMP 8 → 3 SHAKE_FREQ, SHAKE_ZETA, the submit handler
Color-fill wipe duration WIPE_MS 420 → 900 The clip-path shape, the measured diagonal, the checked value

All nine keep a real <input type="checkbox"> under the decorative box rather than a <div> with a click handler, so Space and Tab work without any extra code, and aria-checked stays in sync automatically because the browser is the one setting it. Item 02 and item 05 go one step further and set the native indeterminate and checked properties directly from script, so a screen reader announces the mixed and select-all states correctly instead of only seeing a custom CSS class. Item 08's lock toggles the real disabled property too, which is what keeps a locked checkbox out of both keyboard focus and native form submission, not just out of pointer-events.

FAQ

Why does a single checkbox need nine different toggle patterns instead of one?

Because the value doing the changing isn't always the same thing: a plain checked flip suits items like 01, 03, and 06, an indeterminate ratio suits items like 02 and 05, and a count suits item 04, so a pattern built for one axis usually looks wrong forced onto another.

Is this a "css custom checkbox generator," or nine separate hand-written patterns?

Nine separate patterns — each demo above is its own self-contained script with its own constant set, and the code fence next to it is the exact block that runs it, not a generated template with variables swapped in.

Does item 02's dash come from a native :indeterminate CSS selector or is it drawn by script?

Both play a part: paintParent() sets the real indeterminate property on the <input> so assistive tech and any :indeterminate selector elsewhere still see it correctly, but the dash's own background alpha and shape are painted from the computed ratio value in script rather than left to a CSS pseudo-class alone.

Sources: MDN — <input type="checkbox"> · MDN — :indeterminate · MDN — prefers-reduced-motion · MDN — ARIA checkbox role

Enter the archive password

The password is inside this article. You will find it as you read.