Ballot

No dependencies
Code format
React + Tailwind
import { useState } from 'react';

const css = `
  @keyframes cb-ballot-drop { 0% { transform: translateY(-16px) rotate(-6deg); } 55% { transform: translateY(14px) rotate(0deg); } 100% { transform: translateY(14px); } }
  @keyframes cb-ballot-rise { 0% { transform: translateY(14px); } 100% { transform: translateY(-16px) rotate(-6deg); } }
  @keyframes cb-ballot-thunk { 50% { transform: translateY(1.5px) scaleY(0.97); } }
  .cb-ballot { position: relative; display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; cursor: pointer; user-select: none; }
  .cb-ballot__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
  .cb-ballot__box { position: relative; width: 44px; height: 56px; }
  .cb-ballot__input:focus-visible + .cb-ballot__box { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; border-radius: 6px; }
  .cb-ballot__paper {
    position: absolute;
    left: 13px;
    top: 10px;
    width: 18px;
    height: 24px;
    border-radius: 2px;
    background: light-dark(#373a40, #f1f2f3);
    box-shadow: inset 0 -6px 0 -5px light-dark(#6d7179, #7d8087);
    transform: translateY(-16px) rotate(-6deg);
  }
  .cb-ballot__paper svg { position: absolute; left: 4px; top: 5px; width: 10px; height: 10px; }
  .cb-ballot__paper path { stroke: light-dark(#fbfcfd, #121417); stroke-width: 2; stroke-linecap: round; }
  .cb-ballot__front {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: 34px;
    border-radius: 4px 4px 7px 7px;
    background: linear-gradient(light-dark(#dbdde1, #2e3035), light-dark(#ebedf0, #232529));
    box-shadow: inset 0 0 0 1px light-dark(#cbced3, #393c41), 0 8px 14px -8px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
  }
  .cb-ballot__front::before { content: ''; position: absolute; left: 10px; right: 10px; top: -1px; height: 3px; border-radius: 2px; background: light-dark(#fbfcfd, #121417); box-shadow: 0 0 0 1px light-dark(#6d7179, #7d8087); }
  .cb-ballot__front::after { content: 'VOTE'; position: absolute; left: 0; right: 0; top: 13px; color: light-dark(#5f626a, #a7abb2); font-size: 8px; font-weight: 600; letter-spacing: 0.2em; text-align: center; }
  .cb-ballot__input:checked + .cb-ballot__box .cb-ballot__paper { transform: translateY(14px); }
  .cb-ballot[data-armed] .cb-ballot__input:checked + .cb-ballot__box .cb-ballot__paper { animation: cb-ballot-drop 0.6s cubic-bezier(0.5, 0, 0.6, 1) both; }
  .cb-ballot[data-armed] .cb-ballot__input:checked + .cb-ballot__box .cb-ballot__front { animation: cb-ballot-thunk 0.2s 0.32s; }
  .cb-ballot[data-armed] .cb-ballot__input:not(:checked) + .cb-ballot__box .cb-ballot__paper { animation: cb-ballot-rise 0.45s cubic-bezier(0.3, 1.3, 0.5, 1) both; }
  @media (prefers-reduced-motion: reduce) {
    .cb-ballot * { animation: none !important; }
  }
`;

export function BallotCheckbox() {
  const [armed, setArmed] = useState(false);
  return (
    <>
      <style>
        {css}
      </style>
      <label className="cb-ballot" data-armed={armed || undefined}>
        <input className="cb-ballot__input" type="checkbox" onChange={() => setArmed(true)} />
        <span className="cb-ballot__box" aria-hidden="true">
          <span className="cb-ballot__paper">
            <svg viewBox="0 0 10 10">
              <path d="M1.5 1.5l7 7M8.5 1.5l-7 7" />
            </svg>
          </span>
          <span className="cb-ballot__front" />
        </span>
        I voted
      </label>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/checkbox-ballot
Source on GitHub

About this component

An I voted checkbox: ticking it drops the folded ballot down through the slot into the box with a little thunk; unticking lifts it back out.

Dependencies
None
Difficulty
Beginner
Triggers
click, state change
Techniques
keyframes, pseudo elements, transform only
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native checkbox inside a <label>, so Space toggles it and the visible label names it; :focus-visible on the input outlines the visual. Under prefers-reduced-motion the ballot moves instantly.

More checkboxes

See all
Bow
Bubble
Chalk
Paperclip