Fortune Teller

Say it first.
No dependencies
Code format
React + Tailwind
import { useId, useRef, useState } from 'react';

// Four flaps, each hinged on one edge of the square and folded in to the centre.
const FLAPS = [
  { label: 'Love', side: 't', fortune: 'Say it first.', icon: 'M0 4C-6-1-6-6-3-6C-1.5-6 0-5 0-3.5C0-5 1.5-6 3-6C6-6 6-1 0 4Z' },
  { label: 'Work', side: 'r', fortune: 'Ask for more.', icon: 'M-6-2H6V5H-6ZM-2.5-2V-4.5H2.5V-2' },
  { label: 'Luck', side: 'b', fortune: 'Turn left.', icon: 'M0-6L1.8-1.9L6.2-1.8L2.8 1L4 5.4L0 2.9L-4 5.4L-2.8 1L-6.2-1.8L-1.8-1.9Z' },
  { label: 'Home', side: 'l', fortune: 'Call home.', icon: 'M-6 0L0-5.5L6 0M-4.2-1.5V5H4.2V-1.5M-1.2 5V1.5H1.2V5' },
];
const S = 104;

const css = `
  .tabs-fortune-teller { --paper: light-dark(#fbfcfd, #e5e6e9); --ink: light-dark(#373a40, #121417); position: relative; width: 230px; height: 210px; font-family: 'Bradley Hand', 'Segoe Print', 'Chalkboard SE', 'Comic Sans MS', cursive; }
  .tabs-fortune-teller__sheet { position: absolute; left: ${(230 - S) / 2}px; top: ${(210 - S) / 2}px; width: ${S}px; height: ${S}px; perspective: 520px; }
  /* The inside of the paper: creases, and the fortune under each flap. */
  .tabs-fortune-teller__inside { position: absolute; inset: 0; background: linear-gradient(45deg, transparent calc(50% - 0.5px), color-mix(in oklab, var(--ink) 16%, transparent) 50%, transparent calc(50% + 0.5px)), linear-gradient(-45deg, transparent calc(50% - 0.5px), color-mix(in oklab, var(--ink) 16%, transparent) 50%, transparent calc(50% + 0.5px)), color-mix(in oklab, var(--paper) 92%, light-dark(#6d7179, #7d8087)); box-shadow: 0 10px 18px -8px light-dark(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7)); }
  .tabs-fortune-teller__panel { position: absolute; width: 80px; margin-left: -40px; color: var(--ink); font-size: 12.5px; line-height: 1.1; text-align: center; white-space: nowrap; }
  .tabs-fortune-teller__panel[hidden] { display: block; visibility: hidden; transition: visibility 0s 0.4s; }
  .tabs-fortune-teller__panel[data-side='t'] { left: 50%; top: 12px; }
  .tabs-fortune-teller__panel[data-side='b'] { left: 50%; bottom: 12px; }
  .tabs-fortune-teller__panel[data-side='l'] { left: 19px; top: 50%; translate: 0 -50%; rotate: -90deg; }
  .tabs-fortune-teller__panel[data-side='r'] { left: 85px; top: 50%; translate: 0 -50%; rotate: 90deg; }
  /* A flap turns over its hinge and lies open outside the square, a little lifted. */
  .tabs-fortune-teller__flap { position: absolute; inset: 0; transform-style: preserve-3d; transition: transform 0.42s cubic-bezier(0.45, 0, 0.55, 1); pointer-events: none; }
  .tabs-fortune-teller__flap[data-side='t'] { transform-origin: 50% 0; }
  .tabs-fortune-teller__flap[data-side='b'] { transform-origin: 50% 100%; }
  .tabs-fortune-teller__flap[data-side='l'] { transform-origin: 0 50%; }
  .tabs-fortune-teller__flap[data-side='r'] { transform-origin: 100% 50%; }
  .tabs-fortune-teller__flap[data-open] { transition: transform 0.7s cubic-bezier(0.3, 1.3, 0.5, 1) 0.2s; }
  .tabs-fortune-teller__flap[data-open][data-side='t'] { transform: rotateX(170deg); }
  .tabs-fortune-teller__flap[data-open][data-side='b'] { transform: rotateX(-170deg); }
  .tabs-fortune-teller__flap[data-open][data-side='l'] { transform: rotateY(-170deg); }
  .tabs-fortune-teller__flap[data-open][data-side='r'] { transform: rotateY(170deg); }
  .tabs-fortune-teller__face, .tabs-fortune-teller__back { position: absolute; inset: 0; backface-visibility: hidden; }
  .tabs-fortune-teller__face { background: var(--paper); }
  .tabs-fortune-teller__face[data-side='t'] { clip-path: polygon(0 0, 100% 0, 50% 50%); background: linear-gradient(var(--paper) 10%, color-mix(in oklab, var(--paper) 90%, light-dark(#6d7179, #7d8087)) 50%); }
  .tabs-fortune-teller__face[data-side='b'] { clip-path: polygon(0 100%, 100% 100%, 50% 50%); background: linear-gradient(0deg, var(--paper) 10%, color-mix(in oklab, var(--paper) 86%, light-dark(#6d7179, #7d8087)) 50%); }
  .tabs-fortune-teller__face[data-side='l'] { clip-path: polygon(0 0, 0 100%, 50% 50%); background: linear-gradient(90deg, var(--paper) 10%, color-mix(in oklab, var(--paper) 88%, light-dark(#6d7179, #7d8087)) 50%); }
  .tabs-fortune-teller__face[data-side='r'] { clip-path: polygon(100% 0, 100% 100%, 50% 50%); background: linear-gradient(-90deg, var(--paper) 10%, color-mix(in oklab, var(--paper) 84%, light-dark(#6d7179, #7d8087)) 50%); }
  .tabs-fortune-teller__back { background: color-mix(in oklab, var(--paper) 86%, light-dark(#6d7179, #7d8087)); }
  .tabs-fortune-teller__back[data-side='t'] { transform: rotateX(180deg); clip-path: polygon(0 100%, 100% 100%, 50% 50%); }
  .tabs-fortune-teller__back[data-side='b'] { transform: rotateX(180deg); clip-path: polygon(0 0, 100% 0, 50% 50%); }
  .tabs-fortune-teller__back[data-side='l'] { transform: rotateY(180deg); clip-path: polygon(100% 0, 100% 100%, 50% 50%); }
  .tabs-fortune-teller__back[data-side='r'] { transform: rotateY(180deg); clip-path: polygon(0 0, 0 100%, 50% 50%); }
  .tabs-fortune-teller__mark { position: absolute; display: grid; justify-items: center; gap: 1px; width: 50px; margin-left: -25px; color: var(--ink); font-size: 13px; line-height: 1; }
  .tabs-fortune-teller__mark svg { width: 13px; height: 13px; overflow: visible; fill: none; stroke: currentColor; stroke-width: 1.3; stroke-linejoin: round; stroke-linecap: round; }
  .tabs-fortune-teller__mark[data-side='t'] { left: 50%; top: 9px; }
  .tabs-fortune-teller__mark[data-side='b'] { left: 50%; bottom: 9px; }
  .tabs-fortune-teller__mark[data-side='l'] { left: 21px; top: 50%; translate: 0 -50%; }
  .tabs-fortune-teller__mark[data-side='r'] { left: 83px; top: 50%; translate: 0 -50%; }
  /* The tabs are the four triangles, shaped so only the flap itself takes the click. */
  .tabs-fortune-teller__tab { position: absolute; inset: 0; padding: 0; border: 0; background: none; cursor: pointer; }
  .tabs-fortune-teller__tab[data-side='t'] { clip-path: polygon(0 0, 100% 0, 50% 50%); }
  .tabs-fortune-teller__tab[data-side='b'] { clip-path: polygon(0 100%, 100% 100%, 50% 50%); }
  .tabs-fortune-teller__tab[data-side='l'] { clip-path: polygon(0 0, 0 100%, 50% 50%); }
  .tabs-fortune-teller__tab[data-side='r'] { clip-path: polygon(100% 0, 100% 100%, 50% 50%); }
  .tabs-fortune-teller__tab:focus-visible { outline: none; }
  .tabs-fortune-teller__rings { position: absolute; inset: -4px; width: ${S + 8}px; height: ${S + 8}px; overflow: visible; pointer-events: none; fill: none; stroke: var(--ink); stroke-width: 2; stroke-linejoin: round; }
  .tabs-fortune-teller__rings path { opacity: 0; }
  .tabs-fortune-teller__tab[data-side='t']:focus-visible ~ .tabs-fortune-teller__rings [data-side='t'],
  .tabs-fortune-teller__tab[data-side='r']:focus-visible ~ .tabs-fortune-teller__rings [data-side='r'],
  .tabs-fortune-teller__tab[data-side='b']:focus-visible ~ .tabs-fortune-teller__rings [data-side='b'],
  .tabs-fortune-teller__tab[data-side='l']:focus-visible ~ .tabs-fortune-teller__rings [data-side='l'] { opacity: 1; }
  @media (prefers-reduced-motion: reduce) {
    .tabs-fortune-teller__flap, .tabs-fortune-teller__flap[data-open] { transition: none; }
  }
`;
const RING = {
  t: 'M4 4H108L56 56Z',
  r: 'M108 4V108L56 56Z',
  b: 'M4 108H108L56 56Z',
  l: 'M4 4V108L56 56Z',
};

export function Preview() {
  const id = useId();
  const [sel, setSel] = useState(0);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const n = FLAPS.length;
  const onKeyDown = (e: React.KeyboardEvent) => {
    const next = { ArrowRight: (sel + 1) % n, ArrowDown: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, ArrowUp: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
    if (next === undefined) return;
    e.preventDefault();
    setSel(next);
    tabs.current[next]?.focus();
  };
  return (
    <>
      <style href="kit-tabs-fortune-teller" precedence="kit">
        {css}
      </style>
      <div className="tabs-fortune-teller">
        <div className="tabs-fortune-teller__sheet">
          <div className="tabs-fortune-teller__inside">
            {FLAPS.map((fl, i) => (
              <div key={fl.label} className="tabs-fortune-teller__panel" data-side={fl.side} role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
                {fl.fortune}
              </div>
            ))}
          </div>
          {FLAPS.map((fl, i) => (
            <span key={fl.label} className="tabs-fortune-teller__flap" data-side={fl.side} data-open={sel === i ? '' : undefined} aria-hidden="true">
              <span className="tabs-fortune-teller__face" data-side={fl.side}>
                <span className="tabs-fortune-teller__mark" data-side={fl.side}>
                  {fl.side !== 'b' && (
                    <svg viewBox="-7 -7 14 14">
                      <path d={fl.icon} />
                    </svg>
                  )}
                  {fl.label}
                  {fl.side === 'b' && (
                    <svg viewBox="-7 -7 14 14">
                      <path d={fl.icon} />
                    </svg>
                  )}
                </span>
              </span>
              <span className="tabs-fortune-teller__back" data-side={fl.side} />
            </span>
          ))}
          <div role="tablist" aria-label="Fortunes" onKeyDown={onKeyDown}>
            {FLAPS.map((fl, i) => (
              <button
                key={fl.label}
                ref={(el) => {
                  tabs.current[i] = el;
                }}
                className="tabs-fortune-teller__tab"
                data-side={fl.side}
                type="button"
                role="tab"
                id={`${id}-tab-${i}`}
                aria-label={fl.label}
                aria-selected={sel === i}
                aria-controls={`${id}-panel-${i}`}
                tabIndex={sel === i ? 0 : -1}
                onClick={() => setSel(i)}
              />
            ))}
            <svg className="tabs-fortune-teller__rings" viewBox={`0 0 ${S + 8} ${S + 8}`} aria-hidden="true">
              {FLAPS.map((fl) => (
                <path key={fl.side} data-side={fl.side} d={RING[fl.side as keyof typeof RING]} />
              ))}
            </svg>
          </div>
        </div>
      </div>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/tab-fortune-teller
Source on GitHub

About this component

A paper fortune teller: pick a flap and it unfolds over its crease to show the fortune written inside, while the last one folds back down.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, 3d
Techniques
3d transform, perspective, preserve 3d, clip path, css transition
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Each flap is named, and all four arrow keys move between them. The drawing is aria-hidden. Under prefers-reduced-motion the flaps open and shut at once.

More tabs

See all
Wool, double-breasted and fully lined. Sizes S to XL.
Hangers
Ladder
A 7 cm blade that locks open, for parcels, rope and apples.
Multitool
Pens