Pinball

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

const css = `
  @keyframes toggle-pinball-plunge { 0% { transform: scaleX(1); } 35% { transform: scaleX(0.35); } 42% { transform: scaleX(1.15); } 55%, 100% { transform: scaleX(1); } }
  @keyframes toggle-pinball-shoot {
    0% { transform: translateX(0) scale(1); }
    35% { transform: translateX(-7px) scale(1); animation-timing-function: cubic-bezier(0.1, 0.7, 0.3, 1); }
    72% { transform: translateX(55px) scale(1); animation-timing-function: ease-in-out; }
    84% { transform: translateX(46px) scale(1); }
    100% { transform: translateX(49px) scale(0.75); }
  }
  @keyframes toggle-pinball-return {
    0% { transform: translateX(49px) scale(0.75); }
    18% { transform: translateX(49px) scale(1); animation-timing-function: cubic-bezier(0.3, 0, 0.2, 1); }
    100% { transform: translateX(0) scale(1); }
  }
  .toggle-pinball { position: relative; display: inline-flex; align-items: center; gap: 12px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; cursor: pointer; user-select: none; }
  .toggle-pinball__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
  .toggle-pinball__track {
    position: relative;
    width: 90px;
    height: 32px;
    border-radius: 999px;
    background: light-dark(#f3f4f7, #1a1c1f);
    box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
  }
  .toggle-pinball__input:focus-visible + .toggle-pinball__track { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
  .toggle-pinball__spring {
    position: absolute;
    left: 6px;
    top: 12px;
    width: 12px;
    height: 8px;
    background: repeating-linear-gradient(90deg, light-dark(#6d7179, #7d8087) 0 1.5px, transparent 1.5px 3.5px);
    transform-origin: 0 50%;
  }
  .toggle-pinball__spring::after { content: ''; position: absolute; right: -3px; top: -3px; width: 3px; height: 14px; border-radius: 1px; background: light-dark(#5f626a, #a7abb2); }
  .toggle-pinball__hole {
    position: absolute;
    right: 7px;
    top: 8px;
    width: 16px;
    height: 16px;
    border-radius: 50%;
    background: light-dark(#fbfcfd, #121417);
    box-shadow: inset 0 0 0 1.5px light-dark(#cbced3, #393c41);
    transition: box-shadow 0.3s;
  }
  .toggle-pinball__ball {
    position: absolute;
    left: 25px;
    top: 10px;
    width: 12px;
    height: 12px;
    border-radius: 50%;
    background: radial-gradient(circle at 35% 30%, light-dark(#373a40, #f1f2f3), light-dark(#5f626a, #a7abb2) 80%);
  }
  .toggle-pinball__input:checked + .toggle-pinball__track .toggle-pinball__ball { transform: translateX(49px) scale(0.75); }
  .toggle-pinball__input:checked + .toggle-pinball__track .toggle-pinball__hole { box-shadow: inset 0 0 0 1.5px light-dark(#373a40, #f1f2f3), 0 0 10px 1px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 45%, transparent); transition-delay: 0.6s; }
  .toggle-pinball[data-armed] .toggle-pinball__input:checked + .toggle-pinball__track .toggle-pinball__spring { animation: toggle-pinball-plunge 0.75s ease-out; }
  .toggle-pinball[data-armed] .toggle-pinball__input:checked + .toggle-pinball__track .toggle-pinball__ball { animation: toggle-pinball-shoot 0.75s ease-in; }
  .toggle-pinball[data-armed] .toggle-pinball__input:not(:checked) + .toggle-pinball__track .toggle-pinball__ball { animation: toggle-pinball-return 0.7s; }
  @media (prefers-reduced-motion: reduce) {
    .toggle-pinball__track * { animation: none !important; transition-delay: 0s !important; }
  }
`;

export function PinballToggle() {
  const [armed, setArmed] = useState(false);
  return (
    <>
      <style>
        {css}
      </style>
      <label className="toggle-pinball" data-armed={armed || undefined}>
        <input className="toggle-pinball__input" type="checkbox" role="switch" onChange={() => setArmed(true)} />
        <span className="toggle-pinball__track" aria-hidden="true">
          <span className="toggle-pinball__spring" />
          <span className="toggle-pinball__hole" />
          <span className="toggle-pinball__ball" />
        </span>
        Launch
      </label>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/toggle-pinball
Source on GitHub

About this component

A launch switch like a pinball lane: the plunger compresses and fires, the ball rattles off the end and drops into the hole, which lights up. Off, the ball rolls back.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, spring
Techniques
keyframes, state machine
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native checkbox with role="switch" inside a <label>, so Space toggles it and screen readers announce a switch; the visible label names it and :focus-visible on the input outlines the visual. Under prefers-reduced-motion the ball moves without the shot.

More toggles

See all
Portal
Prism
Projector
Rack & Pinion