Carton

No dependencies
Code format
React + Tailwind
import { useId } from 'react';

// Box sizes in px for the drawing, and the label's real dimensions in cm.
const SIZES = [
  { value: 's', label: 'S', cm: '20×15×15', w: 58, h: 40, d: 42 },
  { value: 'm', label: 'M', cm: '30×20×20', w: 76, h: 50, d: 52 },
  { value: 'l', label: 'L', cm: '40×25×25', w: 90, h: 56, d: 56 },
  { value: 'xl', label: 'XL', cm: '50×30×30', w: 104, h: 62, d: 60 },
];
const at = (n: number) => `.radio-carton:has(.radio-carton__opt:nth-child(${n + 1}) input:checked)`;
const FOLD = 1.6;

const css = `
  @property --radio-carton-w { syntax: '<length>'; inherits: true; initial-value: 76px; }
  @property --radio-carton-h { syntax: '<length>'; inherits: true; initial-value: 50px; }
  @property --radio-carton-d { syntax: '<length>'; inherits: true; initial-value: 52px; }
  .radio-carton { --kraft: light-dark(#6d7179, #7d8087); --kraft-hi: light-dark(#cbced3, #a7abb2); --stencil: light-dark(#373a40, #1a1c1f); display: grid; justify-items: center; gap: 4px; transition: --radio-carton-w 0.55s cubic-bezier(0.5, 0, 0.2, 1) 0.32s, --radio-carton-h 0.55s cubic-bezier(0.5, 0, 0.2, 1) 0.32s, --radio-carton-d 0.55s cubic-bezier(0.5, 0, 0.2, 1) 0.32s; font-family: Stencil, 'Stencil Std', Impact, 'Arial Black', sans-serif; }
  ${SIZES.map((s, i) => `${at(i)} { --radio-carton-w: ${s.w}px; --radio-carton-h: ${s.h}px; --radio-carton-d: ${s.d}px; }`).join('\n  ')}
  .radio-carton__scene { position: relative; width: 262px; height: 168px; perspective: 800px; }
  .radio-carton__floor { position: absolute; left: 50%; top: 150px; width: calc(var(--radio-carton-w) * 1.7); height: 12px; translate: -50% -50%; border-radius: 50%; box-shadow: inset 0 0 0 40px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); filter: blur(7px); }
  .radio-carton__box { --w: var(--radio-carton-w); --h: var(--radio-carton-h); --d: var(--radio-carton-d); position: absolute; left: 50%; top: calc(146px - var(--h) / 2 - var(--d) * 0.2); width: 0; height: 0; transform-style: preserve-3d; transform: rotateX(-26deg) rotateY(-38deg); }
  .radio-carton__face { position: absolute; left: 0; top: 0; transform-style: preserve-3d; background: repeating-linear-gradient(90deg, transparent 0 3px, color-mix(in oklab, var(--kraft) 88%, light-dark(#fbfcfd, #121417)) 3px 4px), var(--kraft); }
  .radio-carton__face--front { width: var(--w); height: var(--h); transform: translate(-50%, -50%) translateZ(calc(var(--d) / 2)); }
  .radio-carton__face--back { width: var(--w); height: var(--h); transform: translate(-50%, -50%) rotateY(180deg) translateZ(calc(var(--d) / 2)); }
  .radio-carton__face--right { width: var(--d); height: var(--h); transform: translate(-50%, -50%) rotateY(90deg) translateZ(calc(var(--w) / 2)); box-shadow: inset 0 0 0 200px light-dark(rgba(0, 0, 0, 0.07), rgba(0, 0, 0, 0.22)); }
  .radio-carton__face--left { width: var(--d); height: var(--h); transform: translate(-50%, -50%) rotateY(-90deg) translateZ(calc(var(--w) / 2)); box-shadow: inset 0 0 0 200px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3)); }
  .radio-carton__face--bottom { width: var(--w); height: var(--d); transform: translate(-50%, -50%) rotateX(-90deg) translateZ(calc(var(--h) / 2)); box-shadow: inset 0 0 0 200px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); }
  /* Flaps hang off each wall's top edge and fold inward; the long ones go on last. */
  .radio-carton__flap { position: absolute; left: 0; bottom: 100%; width: 100%; transform-origin: 50% 100%; background: var(--kraft-hi); box-shadow: inset 0 -1px 0 light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.25)); transform: rotateX(90deg); }
  .radio-carton__flap--major { height: calc(var(--d) / 2); transform: translateY(-0.6px) rotateX(90deg); }
  .radio-carton__flap--minor { height: calc(var(--w) * 0.36); }
  .radio-carton__face--right .radio-carton__flap, .radio-carton__face--left .radio-carton__flap { box-shadow: inset 0 0 0 200px light-dark(rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.12)); }
  .radio-carton__tape { position: absolute; left: -2px; right: -2px; top: -5px; height: 11px; background: color-mix(in oklab, var(--kraft-hi) 50%, light-dark(#373a40, #f1f2f3)); opacity: 0.85; transform-origin: 0 50%; transform: scaleX(1); }
  ${SIZES.map((_, i) => `${at(i)} .radio-carton__flap--major { animation: radio-carton-major${i} ${FOLD}s cubic-bezier(0.45, 0, 0.3, 1); }\n  ${at(i)} .radio-carton__flap--minor { animation: radio-carton-minor${i} ${FOLD}s cubic-bezier(0.45, 0, 0.3, 1); }\n  ${at(i)} .radio-carton__tape { animation: radio-carton-tape${i} ${FOLD + 0.35}s cubic-bezier(0.3, 0, 0.2, 1); }\n  @keyframes radio-carton-major${i} { 0% { transform: translateY(-0.6px) rotateX(90deg); } 14%, 74% { transform: translateY(-0.6px) rotateX(-32deg); } 93%, 100% { transform: translateY(-0.6px) rotateX(90deg); } }\n  @keyframes radio-carton-minor${i} { 0%, 4% { transform: rotateX(90deg); } 20%, 58% { transform: rotateX(-22deg); } 74%, 100% { transform: rotateX(90deg); } }\n  @keyframes radio-carton-tape${i} { 0%, 80% { transform: scaleX(0); } 100% { transform: scaleX(1); } }`).join('\n  ')}
  .radio-carton__print { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; color: var(--stencil); }
  .radio-carton__print > b { grid-area: 1 / 1; font-size: calc(var(--h) * 0.5); font-weight: 400; line-height: 1; opacity: 0; transition: opacity 0.2s 0.3s; }
  .radio-carton__arrows { position: absolute; left: 5px; top: 4px; width: 12px; height: 10px; fill: none; stroke: var(--stencil); stroke-width: 1.6; }
  .radio-carton__fragile { position: absolute; left: 50%; top: 50%; translate: -50% -50%; rotate: -12deg; padding: 1px 4px; border: 1.2px solid var(--stencil); color: var(--stencil); font-size: 8px; letter-spacing: 0.08em; white-space: nowrap; }
  ${SIZES.map((_, i) => `${at(i)} .radio-carton__print > :nth-child(${i + 1})`).join(', ')} { opacity: 1; }
  .radio-carton__opts { display: flex; gap: 6px; }
  .radio-carton__opt { position: relative; display: grid; justify-items: center; min-width: 54px; padding: 5px 2px 4px; border: 1.5px dashed transparent; border-radius: 4px; color: light-dark(#6d7179, #7d8087); cursor: pointer; transition: color 0.25s, border-color 0.25s; }
  .radio-carton__opt:hover { color: light-dark(#42454c, #e5e6e9); }
  .radio-carton__opt:has(input:checked) { color: light-dark(#373a40, #f1f2f3); border-color: light-dark(#6d7179, #7d8087); }
  .radio-carton__opt:has(input:focus-visible) { outline: 1.5px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  .radio-carton__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
  .radio-carton__opt b { font-size: 16px; font-weight: 400; line-height: 1; }
  .radio-carton__opt small { margin-top: 2px; font: 600 8.5px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; }
  @media (prefers-reduced-motion: reduce) {
    .radio-carton, .radio-carton * { animation: none !important; transition: none !important; }
  }
`;

export function Preview() {
  const name = useId();
  return (
    <>
      <style href="kit-radios-carton" precedence="kit">
        {css}
      </style>
      <div className="radio-carton">
        <div className="radio-carton__scene" aria-hidden="true">
          <span className="radio-carton__floor" />
          <span className="radio-carton__box">
            <span className="radio-carton__face radio-carton__face--bottom" />
            <span className="radio-carton__face radio-carton__face--back">
              <span className="radio-carton__flap radio-carton__flap--major" />
            </span>
            <span className="radio-carton__face radio-carton__face--left">
              <span className="radio-carton__flap radio-carton__flap--minor" />
            </span>
            <span className="radio-carton__face radio-carton__face--right">
              <span className="radio-carton__flap radio-carton__flap--minor" />
              <span className="radio-carton__fragile">FRAGILE</span>
            </span>
            <span className="radio-carton__face radio-carton__face--front">
              <span className="radio-carton__print">
                {SIZES.map((s) => (
                  <b key={s.value}>{s.label}</b>
                ))}
              </span>
              <svg className="radio-carton__arrows" viewBox="0 0 12 10">
                <path d="M3 9V2M1 4L3 1.5L5 4M9 9V2M7 4L9 1.5L11 4" />
              </svg>
              <span className="radio-carton__flap radio-carton__flap--major">
                <span className="radio-carton__tape" />
              </span>
            </span>
          </span>
        </div>
        <div className="radio-carton__opts" role="radiogroup" aria-label="Box size">
          {SIZES.map((s, i) => (
            <label key={s.value} className="radio-carton__opt">
              <input className="radio-carton__input" type="radio" name={name} value={s.value} aria-label={`${s.label}, ${s.cm} centimetres`} defaultChecked={i === 1} />
              <b aria-hidden="true">{s.label}</b>
              <small aria-hidden="true">{s.cm}</small>
            </label>
          ))}
        </div>
      </div>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/radio-carton
Source on GitHub

About this component

A box size picker in 3D: the flaps open, the carton grows or shrinks to the new size, the short flaps fold in, the long ones close over them and a strip of tape runs across.

Dependencies
None
Difficulty
Advanced
Triggers
click, state change, 3d, keyframe
Techniques
preserve 3d, 3d transform, css property, keyframes, css variables
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A role="radiogroup" labelled "Box size" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. Each input is named with the size and its dimensions (such as "M, 30×20×20 centimetres"); the box is aria-hidden and focus outlines the option. Under prefers-reduced-motion the box changes size at once.

More radios

See all
? Which framework? ↑↓
CLI
Contact Sheet
Eye Chart
Fairground