Fortune ball
import { useState } from 'react';
const css = `
.cb-oracle { --ball: light-dark(#373a40, #232529); --ball-hi: light-dark(#5f626a, #7d8087); --deep: light-dark(#fbfcfd, #f1f2f3); --water: light-dark(#50535b, #1a1c1f); --die: light-dark(#f3f4f7, #e5e6e9); --die-ink: light-dark(#373a40, #121417); flex: none; position: relative; display: flex; flex-direction: column; align-items: center; gap: 8px; color: light-dark(#373a40, #f1f2f3); cursor: pointer; -webkit-user-select: none; user-select: none; }
.cb-oracle__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-oracle__label { font: italic 700 21px/1 Georgia, 'Times New Roman', serif; letter-spacing: -0.01em; }
.cb-oracle__art { position: relative; display: block; width: 180px; height: 180px; border-radius: 50%; }
.cb-oracle__input:focus-visible ~ .cb-oracle__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
.cb-oracle__sphere { position: absolute; inset: 0; border-radius: 50%; background: radial-gradient(circle at 34% 26%, var(--ball-hi), var(--ball) 46%); box-shadow: 0 12px 18px -8px rgba(0, 0, 0, 0.55), inset -8px -12px 22px rgba(0, 0, 0, 0.35); transform-origin: 50% 70%; }
.cb-oracle:hover .cb-oracle__sphere { animation: cb-oracle-nudge 0.6s ease-in-out; }
@keyframes cb-oracle-nudge {
30% { transform: rotate(-3deg); }
70% { transform: rotate(3deg); }
}
.cb-oracle[data-shake='a'] .cb-oracle__sphere { animation: cb-oracle-shake-a 0.86s cubic-bezier(0.3, 0, 0.4, 1); }
.cb-oracle[data-shake='b'] .cb-oracle__sphere { animation: cb-oracle-shake-b 0.86s cubic-bezier(0.3, 0, 0.4, 1); }
@keyframes cb-oracle-shake-a {
0%, 100% { transform: none; }
12% { transform: translate(-10px, -8px) rotate(-14deg); }
28% { transform: translate(11px, 4px) rotate(13deg); }
44% { transform: translate(-8px, 5px) rotate(-10deg); }
60% { transform: translate(7px, -4px) rotate(7deg); }
78% { transform: translate(-3px, 1px) rotate(-3deg); }
}
@keyframes cb-oracle-shake-b {
0%, 100% { transform: none; }
12% { transform: translate(10px, -8px) rotate(14deg); }
28% { transform: translate(-11px, 4px) rotate(-13deg); }
44% { transform: translate(8px, 5px) rotate(10deg); }
60% { transform: translate(-7px, -4px) rotate(-7deg); }
78% { transform: translate(3px, 1px) rotate(3deg); }
}
.cb-oracle__window { position: absolute; left: 50%; top: 50%; width: 92px; height: 92px; margin: -46px; overflow: hidden; border-radius: 50%; background: radial-gradient(circle at 50% 42%, color-mix(in oklab, var(--water) 72%, var(--deep)), var(--water) 74%); box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.55), 0 0 0 3px color-mix(in oklab, var(--deep) 26%, transparent); }
.cb-oracle__die { position: absolute; left: 50%; top: 50%; width: 76px; height: 68px; margin: -37px -38px; color: var(--die-ink); text-align: center; transition: opacity 0.3s, transform 0.3s, filter 0.3s; }
.cb-oracle__die svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.cb-oracle__die svg path { fill: var(--die); }
.cb-oracle__word { position: absolute; left: 0; right: 0; bottom: 9px; font-weight: 800; line-height: 1; }
.cb-oracle__die--no .cb-oracle__word { font-size: 9.5px; letter-spacing: 0.02em; }
.cb-oracle__die--yes .cb-oracle__word { font-size: 15.5px; letter-spacing: 0.03em; }
/* A die in deep water: small, blurred and dim, until it floats up to the glass. */
.cb-oracle__die--yes { opacity: 0; transform: scale(0.42) rotate(-60deg); filter: blur(3px); }
.cb-oracle__input:checked ~ .cb-oracle__art .cb-oracle__die--yes { opacity: 1; transform: none; filter: none; transition: opacity 0.5s 0.72s, transform 0.75s cubic-bezier(0.3, 1.05, 0.4, 1) 0.72s, filter 0.5s 0.72s; }
.cb-oracle__input:checked ~ .cb-oracle__art .cb-oracle__die--no { opacity: 0; transform: scale(0.42) rotate(60deg); filter: blur(3px); }
.cb-oracle__input:not(:checked) ~ .cb-oracle__art .cb-oracle__die--no { transition: opacity 0.5s 0.72s, transform 0.75s cubic-bezier(0.3, 1.05, 0.4, 1) 0.72s, filter 0.5s 0.72s; }
.cb-oracle__bubble { position: absolute; bottom: -8px; width: var(--s); height: var(--s); left: var(--x); border-radius: 50%; box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--deep) 62%, transparent); opacity: 0; }
.cb-oracle[data-shake='a'] .cb-oracle__bubble, .cb-oracle[data-shake='b'] .cb-oracle__bubble { animation: cb-oracle-rise-a 0.95s ease-out var(--d); }
.cb-oracle[data-shake='b'] .cb-oracle__bubble { animation-name: cb-oracle-rise-b; }
@keyframes cb-oracle-rise-a {
0% { opacity: 0; transform: none; }
20% { opacity: 1; }
100% { opacity: 0; transform: translate(var(--w), -104px); }
}
@keyframes cb-oracle-rise-b {
0% { opacity: 0; transform: none; }
20% { opacity: 1; }
100% { opacity: 0; transform: translate(var(--w), -104px); }
}
.cb-oracle__gleam { position: absolute; left: 24px; top: 16px; width: 44px; height: 24px; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in oklab, var(--deep) 42%, transparent), transparent); transform: rotate(-32deg); pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
.cb-oracle:hover .cb-oracle__sphere, .cb-oracle[data-shake] .cb-oracle__sphere, .cb-oracle[data-shake] .cb-oracle__bubble { animation: none; }
.cb-oracle__die, .cb-oracle__input:checked ~ .cb-oracle__art .cb-oracle__die--yes, .cb-oracle__input:not(:checked) ~ .cb-oracle__art .cb-oracle__die--no { transition: none; }
}
`;
const BUBBLES = [
{ x: '20%', s: '6px', d: '0.06s', w: '6px' },
{ x: '38%', s: '4px', d: '0.2s', w: '-5px' },
{ x: '55%', s: '7px', d: '0.12s', w: '8px' },
{ x: '70%', s: '4px', d: '0.3s', w: '-4px' },
{ x: '46%', s: '5px', d: '0.38s', w: '3px' },
{ x: '28%', s: '3px', d: '0.45s', w: '-3px' },
];
function Triangle() {
return (
<svg viewBox="0 0 76 68" aria-hidden="true">
<path d="M38 3C40 3 41 4 42 6L73 59C75 63 73 65 69 65H7C3 65 1 63 3 59L34 6C35 4 36 3 38 3Z" />
</svg>
);
}
export function OracleCheckbox() {
// Two identical keyframes, swapped so the shake restarts on every change.
const [shake, setShake] = useState<'a' | 'b' | undefined>();
return (
<>
<style>
{css}
</style>
<label className="cb-oracle" data-shake={shake}>
<input className="cb-oracle__input" type="checkbox" onChange={() => setShake((s) => (s === 'a' ? 'b' : 'a'))} />
<span className="cb-oracle__label">Ready to ship?</span>
<span className="cb-oracle__art" aria-hidden="true">
<span className="cb-oracle__sphere">
<span className="cb-oracle__window">
{BUBBLES.map((b) => (
<span key={b.x} className="cb-oracle__bubble" style={{ ['--x' as string]: b.x, ['--s' as string]: b.s, ['--d' as string]: b.d, ['--w' as string]: b.w }} />
))}
<span className="cb-oracle__die cb-oracle__die--no">
<Triangle />
<span className="cb-oracle__word">
NOT
<br />
YET
</span>
</span>
<span className="cb-oracle__die cb-oracle__die--yes">
<Triangle />
<span className="cb-oracle__word">YES</span>
</span>
</span>
<span className="cb-oracle__gleam" />
</span>
</span>
</label>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCbOracle(el) on the mounted element instead.
<label class="cb-oracle">
<input class="cb-oracle__input" type="checkbox"/>
<span class="cb-oracle__label">Ready to ship?</span>
<span class="cb-oracle__art" aria-hidden="true">
<span class="cb-oracle__sphere">
<span class="cb-oracle__window">
<span class="cb-oracle__bubble" style="--x:20%;--s:6px;--d:0.06s;--w:6px"></span>
<span class="cb-oracle__bubble" style="--x:38%;--s:4px;--d:0.2s;--w:-5px"></span>
<span class="cb-oracle__bubble" style="--x:55%;--s:7px;--d:0.12s;--w:8px"></span>
<span class="cb-oracle__bubble" style="--x:70%;--s:4px;--d:0.3s;--w:-4px"></span>
<span class="cb-oracle__bubble" style="--x:46%;--s:5px;--d:0.38s;--w:3px"></span>
<span class="cb-oracle__bubble" style="--x:28%;--s:3px;--d:0.45s;--w:-3px"></span>
<span class="cb-oracle__die cb-oracle__die--no">
<svg viewBox="0 0 76 68" aria-hidden="true">
<path d="M38 3C40 3 41 4 42 6L73 59C75 63 73 65 69 65H7C3 65 1 63 3 59L34 6C35 4 36 3 38 3Z"></path>
</svg>
<span class="cb-oracle__word">
NOT
<br/>
YET
</span>
</span>
<span class="cb-oracle__die cb-oracle__die--yes">
<svg viewBox="0 0 76 68" aria-hidden="true">
<path d="M38 3C40 3 41 4 42 6L73 59C75 63 73 65 69 65H7C3 65 1 63 3 59L34 6C35 4 36 3 38 3Z"></path>
</svg>
<span class="cb-oracle__word">YES</span>
</span>
</span>
<span class="cb-oracle__gleam"></span>
</span>
</span>
</label>.cb-oracle { --ball: light-dark(#373a40, #232529); --ball-hi: light-dark(#5f626a, #7d8087); --deep: light-dark(#fbfcfd, #f1f2f3); --water: light-dark(#50535b, #1a1c1f); --die: light-dark(#f3f4f7, #e5e6e9); --die-ink: light-dark(#373a40, #121417); flex: none; position: relative; display: flex; flex-direction: column; align-items: center; gap: 8px; color: light-dark(#373a40, #f1f2f3); cursor: pointer; -webkit-user-select: none; user-select: none; }
.cb-oracle__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-oracle__label { font: italic 700 21px/1 Georgia, 'Times New Roman', serif; letter-spacing: -0.01em; }
.cb-oracle__art { position: relative; display: block; width: 180px; height: 180px; border-radius: 50%; }
.cb-oracle__input:focus-visible ~ .cb-oracle__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
.cb-oracle__sphere { position: absolute; inset: 0; border-radius: 50%; background: radial-gradient(circle at 34% 26%, var(--ball-hi), var(--ball) 46%); box-shadow: 0 12px 18px -8px rgba(0, 0, 0, 0.55), inset -8px -12px 22px rgba(0, 0, 0, 0.35); transform-origin: 50% 70%; }
.cb-oracle:hover .cb-oracle__sphere { animation: cb-oracle-nudge 0.6s ease-in-out; }
@keyframes cb-oracle-nudge {
30% { transform: rotate(-3deg); }
70% { transform: rotate(3deg); }
}
.cb-oracle[data-shake='a'] .cb-oracle__sphere { animation: cb-oracle-shake-a 0.86s cubic-bezier(0.3, 0, 0.4, 1); }
.cb-oracle[data-shake='b'] .cb-oracle__sphere { animation: cb-oracle-shake-b 0.86s cubic-bezier(0.3, 0, 0.4, 1); }
@keyframes cb-oracle-shake-a {
0%, 100% { transform: none; }
12% { transform: translate(-10px, -8px) rotate(-14deg); }
28% { transform: translate(11px, 4px) rotate(13deg); }
44% { transform: translate(-8px, 5px) rotate(-10deg); }
60% { transform: translate(7px, -4px) rotate(7deg); }
78% { transform: translate(-3px, 1px) rotate(-3deg); }
}
@keyframes cb-oracle-shake-b {
0%, 100% { transform: none; }
12% { transform: translate(10px, -8px) rotate(14deg); }
28% { transform: translate(-11px, 4px) rotate(-13deg); }
44% { transform: translate(8px, 5px) rotate(10deg); }
60% { transform: translate(-7px, -4px) rotate(-7deg); }
78% { transform: translate(3px, 1px) rotate(3deg); }
}
.cb-oracle__window { position: absolute; left: 50%; top: 50%; width: 92px; height: 92px; margin: -46px; overflow: hidden; border-radius: 50%; background: radial-gradient(circle at 50% 42%, color-mix(in oklab, var(--water) 72%, var(--deep)), var(--water) 74%); box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.55), 0 0 0 3px color-mix(in oklab, var(--deep) 26%, transparent); }
.cb-oracle__die { position: absolute; left: 50%; top: 50%; width: 76px; height: 68px; margin: -37px -38px; color: var(--die-ink); text-align: center; transition: opacity 0.3s, transform 0.3s, filter 0.3s; }
.cb-oracle__die svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.cb-oracle__die svg path { fill: var(--die); }
.cb-oracle__word { position: absolute; left: 0; right: 0; bottom: 9px; font-weight: 800; line-height: 1; }
.cb-oracle__die--no .cb-oracle__word { font-size: 9.5px; letter-spacing: 0.02em; }
.cb-oracle__die--yes .cb-oracle__word { font-size: 15.5px; letter-spacing: 0.03em; }
/* A die in deep water: small, blurred and dim, until it floats up to the glass. */
.cb-oracle__die--yes { opacity: 0; transform: scale(0.42) rotate(-60deg); filter: blur(3px); }
.cb-oracle__input:checked ~ .cb-oracle__art .cb-oracle__die--yes { opacity: 1; transform: none; filter: none; transition: opacity 0.5s 0.72s, transform 0.75s cubic-bezier(0.3, 1.05, 0.4, 1) 0.72s, filter 0.5s 0.72s; }
.cb-oracle__input:checked ~ .cb-oracle__art .cb-oracle__die--no { opacity: 0; transform: scale(0.42) rotate(60deg); filter: blur(3px); }
.cb-oracle__input:not(:checked) ~ .cb-oracle__art .cb-oracle__die--no { transition: opacity 0.5s 0.72s, transform 0.75s cubic-bezier(0.3, 1.05, 0.4, 1) 0.72s, filter 0.5s 0.72s; }
.cb-oracle__bubble { position: absolute; bottom: -8px; width: var(--s); height: var(--s); left: var(--x); border-radius: 50%; box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--deep) 62%, transparent); opacity: 0; }
.cb-oracle[data-shake='a'] .cb-oracle__bubble, .cb-oracle[data-shake='b'] .cb-oracle__bubble { animation: cb-oracle-rise-a 0.95s ease-out var(--d); }
.cb-oracle[data-shake='b'] .cb-oracle__bubble { animation-name: cb-oracle-rise-b; }
@keyframes cb-oracle-rise-a {
0% { opacity: 0; transform: none; }
20% { opacity: 1; }
100% { opacity: 0; transform: translate(var(--w), -104px); }
}
@keyframes cb-oracle-rise-b {
0% { opacity: 0; transform: none; }
20% { opacity: 1; }
100% { opacity: 0; transform: translate(var(--w), -104px); }
}
.cb-oracle__gleam { position: absolute; left: 24px; top: 16px; width: 44px; height: 24px; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in oklab, var(--deep) 42%, transparent), transparent); transform: rotate(-32deg); pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
.cb-oracle:hover .cb-oracle__sphere, .cb-oracle[data-shake] .cb-oracle__sphere, .cb-oracle[data-shake] .cb-oracle__bubble { animation: none; }
.cb-oracle__die, .cb-oracle__input:checked ~ .cb-oracle__art .cb-oracle__die--yes, .cb-oracle__input:not(:checked) ~ .cb-oracle__art .cb-oracle__die--no { transition: none; }
}function initCbOracle(root) {
var input = root.querySelector('.cb-oracle__input');
// Two identical keyframes, swapped so the shake restarts on every change.
function onChange() {
root.setAttribute('data-shake', root.getAttribute('data-shake') === 'a' ? 'b' : 'a');
}
input.addEventListener('change', onChange);
return function () {
input.removeEventListener('change', onChange);
};
}
document.querySelectorAll('.cb-oracle').forEach(initCbOracle);Goal: Create a "Fortune ball" checkbox.
Visual behavior: Ask it a question. Tick it and the ball shakes, the water clouds with bubbles and the die floats up out of the dark to say YES; untick and it sinks back to NOT YET.
Technique:
- Two identical shake keyframes are swapped on every change, so the ball shakes each time, and the bubbles rise with the shake, each with its own position, size and delay held in CSS variables.
- The two answers are triangles in the same spot in the window. The one being retired sinks away while the shake runs; the new one waits about 0.7 seconds, then comes up from small and blurred to sharp, as if floating out of dark water.
Accessibility: A native checkbox inside a <label>, so "Ready to ship?" is its name. The ball is aria-hidden and keyboard focus outlines it. Under prefers-reduced-motion it does not shake and the answer changes at once.
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 "Fortune ball" checkbox.
Visual behavior: Ask it a question. Tick it and the ball shakes, the water clouds with bubbles and the die floats up out of the dark to say YES; untick and it sinks back to NOT YET.
Technique:
- Two identical shake keyframes are swapped on every change, so the ball shakes each time, and the bubbles rise with the shake, each with its own position, size and delay held in CSS variables.
- The two answers are triangles in the same spot in the window. The one being retired sinks away while the shake runs; the new one waits about 0.7 seconds, then comes up from small and blurred to sharp, as if floating out of dark water.
Accessibility: A native checkbox inside a <label>, so "Ready to ship?" is its name. The ball is aria-hidden and keyboard focus outlines it. Under prefers-reduced-motion it does not shake and the answer changes at once.
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
Ask it a question. Tick it and the ball shakes, the water clouds with bubbles and the die floats up out of the dark to say YES; untick and it sinks back to NOT YET.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, hover, state change, keyframe, delay stagger
- Techniques
- keyframes, css transition, filter, radial gradient, css variables
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native checkbox inside a <label>, so "Ready to ship?" is its name. The ball is aria-hidden and keyboard focus outlines it. Under prefers-reduced-motion it does not shake and the answer changes at once.