Paper Stock
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>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
<div class="radio-paper-stock">
<div class="radio-paper-stock__scene" aria-hidden="true">
<span class="radio-paper-stock__shadow"></span>
<span class="radio-paper-stock__sheet">
<span class="radio-paper-stock__strip" style="--k:0">
<i></i>
<span class="radio-paper-stock__strip" style="--k:1">
<i></i>
<span class="radio-paper-stock__strip" style="--k:2">
<i></i>
<span class="radio-paper-stock__strip" style="--k:3">
<i></i>
<span class="radio-paper-stock__strip" style="--k:4">
<i></i>
<span class="radio-paper-stock__strip" style="--k:5">
<i></i>
<span class="radio-paper-stock__strip" style="--k:6">
<i></i>
<span class="radio-paper-stock__strip" style="--k:7">
<i></i>
<span class="radio-paper-stock__strip" style="--k:8">
<i></i>
<span class="radio-paper-stock__strip" style="--k:9">
<i></i>
</span>
</span>
</span>
</span>
</span>
</span>
</span>
</span>
</span>
</span>
</span>
<svg class="radio-paper-stock__clip" viewBox="0 0 40 34">
<path d="M3 14L20 9L37 14V26Q37 30 33 30H7Q3 30 3 26Z"></path>
<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"></path>
</svg>
</div>
<div class="radio-paper-stock__opts" role="radiogroup" aria-label="Paper weight">
<label class="radio-paper-stock__opt">
<input class="radio-paper-stock__input" type="radio" aria-label="80 gsm, Copy" name="radio-paper-stock" checked="" value="80"/>
<span class="radio-paper-stock__num" aria-hidden="true">
80
<small>gsm</small>
</span>
<span class="radio-paper-stock__name" aria-hidden="true">Copy</span>
</label>
<label class="radio-paper-stock__opt">
<input class="radio-paper-stock__input" type="radio" aria-label="120 gsm, Text" name="radio-paper-stock" value="120"/>
<span class="radio-paper-stock__num" aria-hidden="true">
120
<small>gsm</small>
</span>
<span class="radio-paper-stock__name" aria-hidden="true">Text</span>
</label>
<label class="radio-paper-stock__opt">
<input class="radio-paper-stock__input" type="radio" aria-label="200 gsm, Cover" name="radio-paper-stock" value="200"/>
<span class="radio-paper-stock__num" aria-hidden="true">
200
<small>gsm</small>
</span>
<span class="radio-paper-stock__name" aria-hidden="true">Cover</span>
</label>
<label class="radio-paper-stock__opt">
<input class="radio-paper-stock__input" type="radio" aria-label="350 gsm, Card" name="radio-paper-stock" value="350"/>
<span class="radio-paper-stock__num" aria-hidden="true">
350
<small>gsm</small>
</span>
<span class="radio-paper-stock__name" aria-hidden="true">Card</span>
</label>
</div>
</div>@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 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); }
.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 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); 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) / 9 * var(--radio-paper-stock-dim) * 100%), transparent), color-mix(in oklab, var(--dark) calc((var(--k) + 1) / 9 * 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; }
.radio-paper-stock:has(.radio-paper-stock__opt:nth-child(1) input:checked) { --bend: 4.8; --edge: 1px; --radio-paper-stock-dim: 0.5; --ss: 0.64; --sx: 11.5px; }
.radio-paper-stock:has(.radio-paper-stock__opt:nth-child(2) input:checked) { --bend: 2.9; --edge: 1.4px; --radio-paper-stock-dim: 0.34; --ss: 0.78; --sx: 7.0px; }
.radio-paper-stock:has(.radio-paper-stock__opt:nth-child(3) input:checked) { --bend: 1.25; --edge: 2px; --radio-paper-stock-dim: 0.16; --ss: 0.91; --sx: 3.0px; }
.radio-paper-stock:has(.radio-paper-stock__opt:nth-child(4) input:checked) { --bend: 0.3; --edge: 3px; --radio-paper-stock-dim: 0.04; --ss: 0.98; --sx: 0.7px; }
.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; }
}Goal: Create a "Paper Stock" radio.
Visual behavior: 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.
Technique:
- Native radio inputs sharing one name inside a labelled role="radiogroup", visually hidden but focusable; each option is a <label> wrapping its input, so the arrow keys and clicks both work.
- The scene reacts with :has() on the root, one selector per option (such as :has(.opt:nth-child(2) input:checked)), so no script is needed.
- The sheet is nine strips nested inside each other in 3D, each hinged on the far edge of the one before and bent by the same small angle, so the angles add up into a smooth curve; lighter stock means a bigger angle per strip.
- Every strip transitions its bend on the same wobbly linear() spring, so the tip whips more than the base. The shading darkens toward the tip through a registered custom property (@property) that transitions with it, and the shadow on the table slides and narrows as the sheet droops.
Accessibility: 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.
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 "Paper Stock" radio.
Visual behavior: 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.
Technique:
- Native radio inputs sharing one name inside a labelled role="radiogroup", visually hidden but focusable; each option is a <label> wrapping its input, so the arrow keys and clicks both work.
- The scene reacts with :has() on the root, one selector per option (such as :has(.opt:nth-child(2) input:checked)), so no script is needed.
- The sheet is nine strips nested inside each other in 3D, each hinged on the far edge of the one before and bent by the same small angle, so the angles add up into a smooth curve; lighter stock means a bigger angle per strip.
- Every strip transitions its bend on the same wobbly linear() spring, so the tip whips more than the base. The shading darkens toward the tip through a registered custom property (@property) that transitions with it, and the shadow on the table slides and narrows as the sheet droops.
Accessibility: 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.
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
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.