Gift
import { useState } from 'react';
const css = `
@keyframes cb-gift-pop {
0% { transform: none; animation-timing-function: cubic-bezier(0.2, 0.6, 0.4, 1); }
35% { transform: translate(-3px, -18px) rotate(-30deg); animation-timing-function: cubic-bezier(0.5, 0, 0.8, 0.5); }
75% { transform: translate(-13px, -7px) rotate(-26deg); }
88% { transform: translate(-13px, -9px) rotate(-29deg); }
100% { transform: translate(-13px, -8px) rotate(-28deg); }
}
.cb-gift { position: relative; display: inline-flex; align-items: center; gap: 12px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; cursor: pointer; user-select: none; }
.cb-gift__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-gift__box { position: relative; width: 34px; height: 36px; border-radius: 4px; }
.cb-gift__input:focus-visible + .cb-gift__box { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
.cb-gift__surprise {
position: absolute;
left: 8px;
top: 14px;
width: 18px;
height: 15px;
opacity: 0;
transform: translateY(8px) scale(0.5);
transition: transform 0.2s ease-in, opacity 0.15s ease 0.05s;
}
.cb-gift__surprise path { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2.8; stroke-linecap: round; stroke-linejoin: round; }
.cb-gift__body {
position: absolute;
left: 3px;
bottom: 0;
width: 28px;
height: 20px;
border-radius: 2px 2px 4px 4px;
background: linear-gradient(90deg, transparent 12px, light-dark(#50535b, #c6c8cd) 12px 16px, transparent 16px), light-dark(#cbced3, #393c41);
box-shadow: inset 0 0 0 1px light-dark(#6d7179, #7d8087);
}
.cb-gift__lid {
position: absolute;
left: 1px;
top: 9px;
width: 32px;
height: 8px;
border-radius: 2px;
background: linear-gradient(90deg, transparent 14px, light-dark(#42454c, #e5e6e9) 14px 18px, transparent 18px), light-dark(#6d7179, #7d8087);
box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2);
transform-origin: 0 100%;
transition: transform 0.35s cubic-bezier(0.3, 1.5, 0.5, 1) 0.1s;
}
.cb-gift__bow { position: absolute; top: -6px; width: 9px; height: 7px; border-radius: 50% 50% 20% 50%; border: 2px solid light-dark(#42454c, #e5e6e9); }
.cb-gift__bow--l { left: 8px; transform: rotate(-20deg); }
.cb-gift__bow--r { right: 8px; transform: scaleX(-1) rotate(-20deg); }
.cb-gift__input:checked + .cb-gift__box .cb-gift__lid { transform: translate(-13px, -8px) rotate(-28deg); transition: none; }
.cb-gift__input:checked + .cb-gift__box .cb-gift__surprise { opacity: 1; transform: translateY(-12px); transition: transform 0.5s cubic-bezier(0.3, 1.8, 0.5, 1) 0.12s, opacity 0.1s ease 0.12s; }
.cb-gift[data-armed] .cb-gift__input:checked + .cb-gift__box .cb-gift__lid { animation: cb-gift-pop 0.55s; }
@media (prefers-reduced-motion: reduce) {
.cb-gift__box * { animation: none !important; transition-duration: 0.01s !important; transition-delay: 0s !important; }
}
`;
export function GiftCheckbox() {
const [armed, setArmed] = useState(false);
return (
<>
<style>
{css}
</style>
<label className="cb-gift" data-armed={armed || undefined}>
<input className="cb-gift__input" type="checkbox" onChange={() => setArmed(true)} />
<span className="cb-gift__box" aria-hidden="true">
<svg className="cb-gift__surprise" viewBox="0 0 18 15">
<path d="M3 8L7 12L15 3" />
</svg>
<span className="cb-gift__body" />
<span className="cb-gift__lid">
<span className="cb-gift__bow cb-gift__bow--l" />
<span className="cb-gift__bow cb-gift__bow--r" />
</span>
</span>
Surprise me
</label>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCbGift(el) on the mounted element instead.
<label class="cb-gift"><input class="cb-gift__input" type="checkbox"/><span class="cb-gift__box" aria-hidden="true"><svg class="cb-gift__surprise" viewBox="0 0 18 15"><path d="M3 8L7 12L15 3"></path></svg><span class="cb-gift__body"></span><span class="cb-gift__lid"><span class="cb-gift__bow cb-gift__bow--l"></span><span class="cb-gift__bow cb-gift__bow--r"></span></span></span>Surprise me</label>@keyframes cb-gift-pop {
0% { transform: none; animation-timing-function: cubic-bezier(0.2, 0.6, 0.4, 1); }
35% { transform: translate(-3px, -18px) rotate(-30deg); animation-timing-function: cubic-bezier(0.5, 0, 0.8, 0.5); }
75% { transform: translate(-13px, -7px) rotate(-26deg); }
88% { transform: translate(-13px, -9px) rotate(-29deg); }
100% { transform: translate(-13px, -8px) rotate(-28deg); }
}
.cb-gift { position: relative; display: inline-flex; align-items: center; gap: 12px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; cursor: pointer; user-select: none; }
.cb-gift__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-gift__box { position: relative; width: 34px; height: 36px; border-radius: 4px; }
.cb-gift__input:focus-visible + .cb-gift__box { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
.cb-gift__surprise {
position: absolute;
left: 8px;
top: 14px;
width: 18px;
height: 15px;
opacity: 0;
transform: translateY(8px) scale(0.5);
transition: transform 0.2s ease-in, opacity 0.15s ease 0.05s;
}
.cb-gift__surprise path { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2.8; stroke-linecap: round; stroke-linejoin: round; }
.cb-gift__body {
position: absolute;
left: 3px;
bottom: 0;
width: 28px;
height: 20px;
border-radius: 2px 2px 4px 4px;
background: linear-gradient(90deg, transparent 12px, light-dark(#50535b, #c6c8cd) 12px 16px, transparent 16px), light-dark(#cbced3, #393c41);
box-shadow: inset 0 0 0 1px light-dark(#6d7179, #7d8087);
}
.cb-gift__lid {
position: absolute;
left: 1px;
top: 9px;
width: 32px;
height: 8px;
border-radius: 2px;
background: linear-gradient(90deg, transparent 14px, light-dark(#42454c, #e5e6e9) 14px 18px, transparent 18px), light-dark(#6d7179, #7d8087);
box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2);
transform-origin: 0 100%;
transition: transform 0.35s cubic-bezier(0.3, 1.5, 0.5, 1) 0.1s;
}
.cb-gift__bow { position: absolute; top: -6px; width: 9px; height: 7px; border-radius: 50% 50% 20% 50%; border: 2px solid light-dark(#42454c, #e5e6e9); }
.cb-gift__bow--l { left: 8px; transform: rotate(-20deg); }
.cb-gift__bow--r { right: 8px; transform: scaleX(-1) rotate(-20deg); }
.cb-gift__input:checked + .cb-gift__box .cb-gift__lid { transform: translate(-13px, -8px) rotate(-28deg); transition: none; }
.cb-gift__input:checked + .cb-gift__box .cb-gift__surprise { opacity: 1; transform: translateY(-12px); transition: transform 0.5s cubic-bezier(0.3, 1.8, 0.5, 1) 0.12s, opacity 0.1s ease 0.12s; }
.cb-gift[data-armed] .cb-gift__input:checked + .cb-gift__box .cb-gift__lid { animation: cb-gift-pop 0.55s; }
@media (prefers-reduced-motion: reduce) {
.cb-gift__box * { animation: none !important; transition-duration: 0.01s !important; transition-delay: 0s !important; }
}function initCbGift(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-gift').forEach(initCbGift);Goal: Create a "Gift" checkbox.
Visual behavior: A present checkbox: ticking pops the lid off the box, it flies up and lands askew, and a tick springs out of the box; unticking sinks the tick and closes the lid.
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.
- The lid flies on a keyframe and rests in its tilted, open position; the ribbon is a gradient stripe and the bow two bordered loops.
- The tick sits behind the box front and rises out of it on an overshooting transition.
- 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 lid and the tick change instantly.
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 "Gift" checkbox.
Visual behavior: A present checkbox: ticking pops the lid off the box, it flies up and lands askew, and a tick springs out of the box; unticking sinks the tick and closes the lid.
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.
- The lid flies on a keyframe and rests in its tilted, open position; the ribbon is a gradient stripe and the bow two bordered loops.
- The tick sits behind the box front and rises out of it on an overshooting transition.
- 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 lid and the tick change instantly.
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 present checkbox: ticking pops the lid off the box, it flies up and lands askew, and a tick springs out of the box; unticking sinks the tick and closes the lid.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- click, state change, spring
- Techniques
- keyframes, css transition, 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 lid and the tick change instantly.