Paper Stock

No dependencies
Code format
React + Tailwind
import { useId, type CSSProperties, type ReactNode } from 'react';

// A sheet sticking out of a clip, built from nine hinged strips. Each strip
// bends a little more than the one before, so the sheet curves; lighter
// paper bends more. `bend` is degrees per strip.
const STOCKS = [
  { value: '80', name: 'Copy', bend: 4.8, edge: 1, dim: 0.5 },
  { value: '120', name: 'Text', bend: 2.9, edge: 1.4, dim: 0.34 },
  { value: '200', name: 'Cover', bend: 1.25, edge: 2, dim: 0.16 },
  { value: '350', name: 'Card', bend: 0.3, edge: 3, dim: 0.04 },
];
const STRIPS = 9;
const at = (n: number) => `.radio-paper-stock:has(.radio-paper-stock__opt:nth-child(${n + 1}) input:checked)`;
const WOBBLE = 'linear(0, 0.262, 0.812, 1.295, 1.491, 1.387, 1.122, 0.874, 0.761, 0.799, 0.925, 1.052, 1.116, 1.104, 1.044, 0.98, 0.944, 0.947, 0.975, 1.007, 1.026, 1.027, 1.014, 0.998, 0.988, 0.986, 0.992, 1, 1.006, 1.007, 1.004, 1, 0.997, 0.997, 0.998, 1, 1)';

const css = `
  @property --radio-paper-stock-dim { syntax: '<number>'; inherits: true; initial-value: 0.5; }
  .radio-paper-stock { --paper: light-dark(#f3f4f7, #e5e6e9); --print: light-dark(#dbdde1, #7d8087); --dark: light-dark(#373a40, #121417); --rim: color-mix(in oklab, var(--paper) 70%, light-dark(#6d7179, #7d8087)); display: grid; justify-items: center; gap: 6px; transition: --radio-paper-stock-dim 1s; }
  .radio-paper-stock__scene { position: relative; width: 262px; height: 150px; perspective: 560px; perspective-origin: 40% 0%; }
  .radio-paper-stock__shadow { position: absolute; left: 50px; top: 118px; width: 180px; height: 10px; border-radius: 50%; box-shadow: inset 0 0 0 40px light-dark(rgba(0, 0, 0, 0.13), rgba(0, 0, 0, 0.42)); filter: blur(7px); transform: translateX(var(--sx, 0px)) scaleX(var(--ss, 1)); transform-origin: 0 50%; transition: transform 1.2s ${WOBBLE}; }
  .radio-paper-stock__sheet { position: absolute; left: 48px; top: 30px; width: 22px; height: 88px; transform: rotateX(64deg) rotateZ(-6deg); transform-style: preserve-3d; }
  .radio-paper-stock__strip { box-sizing: border-box; position: absolute; left: 0; top: 0; width: 23px; height: 100%; transform-origin: 0 50%; transform-style: preserve-3d; transform: rotateY(calc(var(--bend) * 1deg)); transition: transform 1.25s ${WOBBLE}; background: repeating-linear-gradient(180deg, transparent 0 14px, color-mix(in oklab, var(--print) 60%, transparent) 14px 15px, transparent 15px 21px) 0 12px / 100% calc(100% - 22px) no-repeat, var(--paper); border-top: 1px solid var(--rim); border-bottom: var(--edge) solid var(--rim); }
  .radio-paper-stock__strip .radio-paper-stock__strip { left: 22px; transition-delay: 0.02s; }
  .radio-paper-stock__strip > i { position: absolute; inset: -1px 0; background: linear-gradient(90deg, color-mix(in oklab, var(--dark) calc(var(--k) / ${STRIPS} * var(--radio-paper-stock-dim) * 100%), transparent), color-mix(in oklab, var(--dark) calc((var(--k) + 1) / ${STRIPS} * var(--radio-paper-stock-dim) * 100%), transparent)); pointer-events: none; }
  .radio-paper-stock__sheet > .radio-paper-stock__strip { transform: none; }
  /* The clip that holds it: a black bulldog clip with its wire handles. */
  .radio-paper-stock__clip { position: absolute; left: 30px; top: 40px; width: 40px; height: 34px; overflow: visible; }
  .radio-paper-stock__clip path { fill: light-dark(#373a40, #232529); stroke: light-dark(#50535b, #393c41); stroke-width: 1; }
  .radio-paper-stock__clip path + path { fill: none; stroke: light-dark(#6d7179, #a7abb2); stroke-width: 1.8; stroke-linecap: round; }
  ${STOCKS.map((s, i) => `${at(i)} { --bend: ${s.bend}; --edge: ${s.edge}px; --radio-paper-stock-dim: ${s.dim}; --ss: ${(1 - s.bend * 0.075).toFixed(2)}; --sx: ${(s.bend * 2.4).toFixed(1)}px; }`).join('\n  ')}
  .radio-paper-stock__opts { display: flex; gap: 20px; }
  .radio-paper-stock__opt { position: relative; display: grid; justify-items: center; gap: 3px; color: light-dark(#6d7179, #7d8087); cursor: pointer; transition: color 0.25s; }
  .radio-paper-stock__opt:hover { color: light-dark(#42454c, #e5e6e9); }
  .radio-paper-stock__opt:has(input:checked) { color: light-dark(#373a40, #f1f2f3); }
  .radio-paper-stock__opt:has(input:focus-visible) { outline: 1.5px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; border-radius: 3px; }
  .radio-paper-stock__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
  .radio-paper-stock__num { font: 400 25px/1 Didot, 'Bodoni 72', 'Bodoni MT', Georgia, serif; letter-spacing: -0.02em; }
  .radio-paper-stock__num small { margin-left: 2px; font: 600 7.5px system-ui, sans-serif; letter-spacing: 0.08em; text-transform: uppercase; vertical-align: 12px; }
  .radio-paper-stock__name { font: 500 9px system-ui, sans-serif; letter-spacing: 0.12em; text-transform: uppercase; }
  @media (prefers-reduced-motion: reduce) {
    .radio-paper-stock * { transition: none !important; }
  }
`;

// Strip k holds strip k + 1 at its far edge, so every bend adds to the last.
function Strips({ k }: { k: number }): ReactNode {
  return (
    <span className="radio-paper-stock__strip" style={{ '--k': k } as CSSProperties}>
      <i />
      {k < STRIPS && <Strips k={k + 1} />}
    </span>
  );
}

export function Preview() {
  const name = useId();
  return (
    <>
      <style href="kit-radios-paper-stock" precedence="kit">
        {css}
      </style>
      <div className="radio-paper-stock">
        <div className="radio-paper-stock__scene" aria-hidden="true">
          <span className="radio-paper-stock__shadow" />
          <span className="radio-paper-stock__sheet">
            <Strips k={0} />
          </span>
          <svg className="radio-paper-stock__clip" viewBox="0 0 40 34">
            <path d="M3 14L20 9L37 14V26Q37 30 33 30H7Q3 30 3 26Z" />
            <path d="M11 11V3.5Q11 1 13.5 1H16.5Q19 1 19 3.5V10M29 11V3.5Q29 1 26.5 1H23.5Q21 1 21 3.5V10" />
          </svg>
        </div>
        <div className="radio-paper-stock__opts" role="radiogroup" aria-label="Paper weight">
          {STOCKS.map((s, i) => (
            <label key={s.value} className="radio-paper-stock__opt">
              <input className="radio-paper-stock__input" type="radio" name={name} value={s.value} aria-label={`${s.value} gsm, ${s.name}`} defaultChecked={i === 0} />
              <span className="radio-paper-stock__num" aria-hidden="true">
                {s.value}
                <small>gsm</small>
              </span>
              <span className="radio-paper-stock__name" aria-hidden="true">
                {s.name}
              </span>
            </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-paper-stock
Source on GitHub

About this component

A paper weight picker: a sheet held in a bulldog clip droops under its own weight, and springs limper or stiffer as you change the stock, wobbling before it settles.

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

A role="radiogroup" labelled "Paper weight" 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 its weight and stock (such as "80 gsm, Copy"); the sheet is aria-hidden and focus outlines the option. Under prefers-reduced-motion the sheet bends at once.

More radios

See all
Pigeonhole
Pillbox
Sewing
Stickers