Marble
import { useState } from 'react';
const css = `
@keyframes cb-marble-in {
0% { transform: translate(2px, -2.5px) rotate(0deg); animation-timing-function: cubic-bezier(0.45, 0, 0.9, 0.7); }
55% { transform: translate(28px, 7px) rotate(330deg); animation-timing-function: linear; }
68% { transform: translate(40px, 4px) rotate(420deg); animation-timing-function: cubic-bezier(0.5, 0, 1, 0.6); }
84% { transform: translate(51px, 15px) rotate(500deg); animation-timing-function: ease-out; }
92% { transform: translate(51px, 12.5px) rotate(520deg); animation-timing-function: ease-in; }
100% { transform: translate(51px, 15px) rotate(530deg); }
}
@keyframes cb-marble-out {
0% { transform: translate(51px, 15px) rotate(530deg); animation-timing-function: ease-out; }
40% { transform: translate(30px, -14px) rotate(380deg); animation-timing-function: ease-in; }
100% { transform: translate(2px, -2.5px) rotate(0deg); }
}
@keyframes cb-marble-ring { 0%, 82% { box-shadow: inset 0 1px 3px light-dark(#fbfcfd, #121417), 0 0 0 0 light-dark(#50535b, #c6c8cd); } 90% { box-shadow: inset 0 1px 3px light-dark(#fbfcfd, #121417), 0 0 0 3px color-mix(in oklab, light-dark(#50535b, #c6c8cd) 50%, transparent); } 100% { box-shadow: inset 0 1px 3px light-dark(#fbfcfd, #121417), 0 0 0 6px transparent; } }
.cb-marble { position: relative; display: inline-flex; align-items: center; gap: 12px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; cursor: pointer; user-select: none; }
.cb-marble__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-marble__track { position: relative; width: 72px; height: 38px; }
.cb-marble__ramp { position: absolute; left: 0; top: 0; width: 40px; height: 24px; overflow: visible; }
.cb-marble__ramp path { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
.cb-marble__box {
position: absolute;
right: 0;
top: 6px;
width: 30px;
height: 30px;
border-radius: 8px;
background: light-dark(#ebedf0, #232529);
box-shadow: inset 0 0 0 1.5px light-dark(#cbced3, #393c41);
}
.cb-marble__input:focus-visible + .cb-marble__track .cb-marble__box { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
.cb-marble__hole {
position: absolute;
left: 7px;
top: 7px;
width: 16px;
height: 16px;
border-radius: 50%;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 1px 3px light-dark(#fbfcfd, #121417);
}
.cb-marble__ball {
position: absolute;
left: 0;
top: 0;
width: 12px;
height: 12px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, light-dark(#373a40, #f1f2f3), light-dark(#50535b, #c6c8cd) 45%, light-dark(#cbced3, #393c41));
transform: translate(2px, -2.5px);
z-index: 1;
}
.cb-marble__ball::after { content: ''; position: absolute; left: 2px; top: 5px; width: 8px; height: 1.5px; border-radius: 1px; background: color-mix(in oklab, light-dark(#6d7179, #7d8087) 70%, transparent); }
.cb-marble__input:checked + .cb-marble__track .cb-marble__ball { transform: translate(51px, 15px) rotate(530deg); }
.cb-marble[data-armed] .cb-marble__input:checked + .cb-marble__track .cb-marble__ball { animation: cb-marble-in 0.85s; }
.cb-marble[data-armed] .cb-marble__input:not(:checked) + .cb-marble__track .cb-marble__ball { animation: cb-marble-out 0.55s; }
.cb-marble[data-armed] .cb-marble__input:checked + .cb-marble__track .cb-marble__hole { animation: cb-marble-ring 0.85s linear; }
@media (prefers-reduced-motion: reduce) {
.cb-marble__track * { animation: none !important; }
}
`;
export function MarbleCheckbox() {
const [armed, setArmed] = useState(false);
return (
<>
<style>
{css}
</style>
<label className="cb-marble" data-armed={armed || undefined}>
<input className="cb-marble__input" type="checkbox" onChange={() => setArmed(true)} />
<span className="cb-marble__track" aria-hidden="true">
<svg className="cb-marble__ramp" viewBox="0 0 40 24">
<path d="M1 8L36 20.5" />
</svg>
<span className="cb-marble__box">
<span className="cb-marble__hole" />
</span>
<span className="cb-marble__ball" />
</span>
Count me in
</label>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCbMarble(el) on the mounted element instead.
<label class="cb-marble"><input class="cb-marble__input" type="checkbox"/><span class="cb-marble__track" aria-hidden="true"><svg class="cb-marble__ramp" viewBox="0 0 40 24"><path d="M1 8L36 20.5"></path></svg><span class="cb-marble__box"><span class="cb-marble__hole"></span></span><span class="cb-marble__ball"></span></span>Count me in</label>@keyframes cb-marble-in {
0% { transform: translate(2px, -2.5px) rotate(0deg); animation-timing-function: cubic-bezier(0.45, 0, 0.9, 0.7); }
55% { transform: translate(28px, 7px) rotate(330deg); animation-timing-function: linear; }
68% { transform: translate(40px, 4px) rotate(420deg); animation-timing-function: cubic-bezier(0.5, 0, 1, 0.6); }
84% { transform: translate(51px, 15px) rotate(500deg); animation-timing-function: ease-out; }
92% { transform: translate(51px, 12.5px) rotate(520deg); animation-timing-function: ease-in; }
100% { transform: translate(51px, 15px) rotate(530deg); }
}
@keyframes cb-marble-out {
0% { transform: translate(51px, 15px) rotate(530deg); animation-timing-function: ease-out; }
40% { transform: translate(30px, -14px) rotate(380deg); animation-timing-function: ease-in; }
100% { transform: translate(2px, -2.5px) rotate(0deg); }
}
@keyframes cb-marble-ring { 0%, 82% { box-shadow: inset 0 1px 3px light-dark(#fbfcfd, #121417), 0 0 0 0 light-dark(#50535b, #c6c8cd); } 90% { box-shadow: inset 0 1px 3px light-dark(#fbfcfd, #121417), 0 0 0 3px color-mix(in oklab, light-dark(#50535b, #c6c8cd) 50%, transparent); } 100% { box-shadow: inset 0 1px 3px light-dark(#fbfcfd, #121417), 0 0 0 6px transparent; } }
.cb-marble { position: relative; display: inline-flex; align-items: center; gap: 12px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; cursor: pointer; user-select: none; }
.cb-marble__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-marble__track { position: relative; width: 72px; height: 38px; }
.cb-marble__ramp { position: absolute; left: 0; top: 0; width: 40px; height: 24px; overflow: visible; }
.cb-marble__ramp path { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
.cb-marble__box {
position: absolute;
right: 0;
top: 6px;
width: 30px;
height: 30px;
border-radius: 8px;
background: light-dark(#ebedf0, #232529);
box-shadow: inset 0 0 0 1.5px light-dark(#cbced3, #393c41);
}
.cb-marble__input:focus-visible + .cb-marble__track .cb-marble__box { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
.cb-marble__hole {
position: absolute;
left: 7px;
top: 7px;
width: 16px;
height: 16px;
border-radius: 50%;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 1px 3px light-dark(#fbfcfd, #121417);
}
.cb-marble__ball {
position: absolute;
left: 0;
top: 0;
width: 12px;
height: 12px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, light-dark(#373a40, #f1f2f3), light-dark(#50535b, #c6c8cd) 45%, light-dark(#cbced3, #393c41));
transform: translate(2px, -2.5px);
z-index: 1;
}
.cb-marble__ball::after { content: ''; position: absolute; left: 2px; top: 5px; width: 8px; height: 1.5px; border-radius: 1px; background: color-mix(in oklab, light-dark(#6d7179, #7d8087) 70%, transparent); }
.cb-marble__input:checked + .cb-marble__track .cb-marble__ball { transform: translate(51px, 15px) rotate(530deg); }
.cb-marble[data-armed] .cb-marble__input:checked + .cb-marble__track .cb-marble__ball { animation: cb-marble-in 0.85s; }
.cb-marble[data-armed] .cb-marble__input:not(:checked) + .cb-marble__track .cb-marble__ball { animation: cb-marble-out 0.55s; }
.cb-marble[data-armed] .cb-marble__input:checked + .cb-marble__track .cb-marble__hole { animation: cb-marble-ring 0.85s linear; }
@media (prefers-reduced-motion: reduce) {
.cb-marble__track * { animation: none !important; }
}function initCbMarble(root) {
var input = root.querySelector('input');
if (!input) return;
// Animate only after the first change, so nothing plays on page load.
function arm() { root.setAttribute('data-armed', ''); }
input.addEventListener('change', arm);
return function () { input.removeEventListener('change', arm); };
}
document.querySelectorAll('.cb-marble').forEach(initCbMarble);Goal: Create a "Marble" checkbox.
Visual behavior: A marble run checkbox: ticking rolls a marble down the ramp, over the edge and into the hole in the box, where it lands with a bounce and the hole glows; unticking pops it back up the ramp.
Technique:
- A real native checkbox (visually hidden: absolute, 1x1, opacity 0) inside a <label>; every visual is aria-hidden and styled from the input with :checked + sibling selectors.
- One keyframe moves and rotates the marble through the ramp, the drop and a small bounce, with its own easing on each segment, so it accelerates down and falls in.
- The marble is a radial gradient with a stripe, so the rolling shows.
- The keyframes only apply once a data-armed flag is set on the first change, so nothing plays on page load; ticking and unticking use differently named keyframes, so each change restarts the right one.
Accessibility: 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 marble moves straight to place.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Marble" checkbox.
Visual behavior: A marble run checkbox: ticking rolls a marble down the ramp, over the edge and into the hole in the box, where it lands with a bounce and the hole glows; unticking pops it back up the ramp.
Technique:
- A real native checkbox (visually hidden: absolute, 1x1, opacity 0) inside a <label>; every visual is aria-hidden and styled from the input with :checked + sibling selectors.
- One keyframe moves and rotates the marble through the ramp, the drop and a small bounce, with its own easing on each segment, so it accelerates down and falls in.
- The marble is a radial gradient with a stripe, so the rolling shows.
- The keyframes only apply once a data-armed flag is set on the first change, so nothing plays on page load; ticking and unticking use differently named keyframes, so each change restarts the right one.
Accessibility: 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 marble moves straight to place.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A marble run checkbox: ticking rolls a marble down the ramp, over the edge and into the hole in the box, where it lands with a bounce and the hole glows; unticking pops it back up the ramp.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change
- Techniques
- keyframes, radial gradient
- 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 marble moves straight to place.