Fortune ball

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

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

Install with shadcn

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

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.

More checkboxes

See all
Peg
Putt
Rake
Searchlight