Elevation

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

const OPTIONS = [
  { value: 'flat', label: 'Flat', note: '0 dp, sits on the page' },
  { value: 'raised', label: 'Raised', note: '4 dp, for cards and tiles' },
  { value: 'floating', label: 'Floating', note: '12 dp, for menus and dialogs' },
];

const css = `
  .radio-elevation { display: grid; justify-items: center; gap: 14px; width: 260px; }
  .radio-elevation { --radio-elevation-shadow: light-dark(rgba(0, 0, 0, 0.22), rgba(0, 0, 0, 0.7)); }
  .radio-elevation__stage { position: relative; display: grid; place-items: center; width: 220px; height: 110px; }
  /* The card lifts on a spring; its shadow drops further, spreads, softens and fades as it rises. */
  .radio-elevation__card {
    position: relative;
    box-sizing: border-box;
    width: 124px;
    height: 66px;
    padding: 12px;
    border-radius: 12px;
    background: light-dark(#ebedf0, #232529);
    box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), 0 1px 1px var(--radio-elevation-shadow);
    transition: transform 0.55s cubic-bezier(0.3, 1.5, 0.5, 1), box-shadow 0.55s cubic-bezier(0.3, 1.2, 0.5, 1);
  }
  .radio-elevation__line { display: block; height: 5px; margin-bottom: 7px; border-radius: 3px; background: light-dark(#cbced3, #393c41); }
  .radio-elevation__line:first-child { width: 60%; background: light-dark(#5f626a, #a7abb2); }
  .radio-elevation:has(.radio-elevation__opt:nth-child(2) input:checked) .radio-elevation__card {
    transform: translateY(-6px);
    box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), 0 2px 3px -1px var(--radio-elevation-shadow), 0 8px 14px -6px var(--radio-elevation-shadow);
  }
  .radio-elevation:has(.radio-elevation__opt:nth-child(3) input:checked) .radio-elevation__card {
    transform: translateY(-16px) scale(1.03);
    box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), 0 6px 10px -4px var(--radio-elevation-shadow), 0 26px 30px -14px var(--radio-elevation-shadow);
  }
  .radio-elevation__read { display: grid; color: light-dark(#50535b, #c6c8cd); font-size: 12px; font-variant-numeric: tabular-nums; }
  .radio-elevation__read > * { grid-area: 1 / 1; text-align: center; opacity: 0; transform: translateY(4px); transition: opacity 0.25s, transform 0.3s; }
  .radio-elevation:has(.radio-elevation__opt:nth-child(1) input:checked) .radio-elevation__read > :nth-child(1),
  .radio-elevation:has(.radio-elevation__opt:nth-child(2) input:checked) .radio-elevation__read > :nth-child(2),
  .radio-elevation:has(.radio-elevation__opt:nth-child(3) input:checked) .radio-elevation__read > :nth-child(3) { opacity: 1; transform: none; }
  .radio-elevation__opts { display: flex; gap: 3px; padding: 4px; border-radius: 12px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
  .radio-elevation__opt { position: relative; flex: 1; padding: 7px 10px; border-radius: 8px; color: light-dark(#5f626a, #a7abb2); font-size: 12.5px; font-weight: 500; text-align: center; white-space: nowrap; cursor: pointer; transition: background-color 0.25s, color 0.25s; }
  .radio-elevation__opt:hover { color: light-dark(#42454c, #e5e6e9); }
  .radio-elevation__opt:has(input:checked) { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
  .radio-elevation__opt:has(input:focus-visible) { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  .radio-elevation__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
  @media (prefers-reduced-motion: reduce) {
    .radio-elevation * { transition: none !important; }
  }
`;

export function ElevationRadio() {
  const name = useId();
  return (
    <>
      <style>
        {css}
      </style>
      <div className="radio-elevation">
        <div className="radio-elevation__stage" aria-hidden="true">
          <div className="radio-elevation__card">
            <span className="radio-elevation__line" />
            <span className="radio-elevation__line" />
            <span className="radio-elevation__line" style={{ width: '80%' }} />
          </div>
        </div>
        <span className="radio-elevation__read" aria-hidden="true">
          {OPTIONS.map((o) => (
            <span key={o.value}>{o.note}</span>
          ))}
        </span>
        <div className="radio-elevation__opts" role="radiogroup" aria-label="Elevation">
          {OPTIONS.map((o, i) => (
            <label key={o.value} className="radio-elevation__opt">
              <input className="radio-elevation__input" type="radio" name={name} value={o.value} defaultChecked={i === 1} />
              {o.label}
            </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-elevation
Source on GitHub

About this component

An elevation picker: the card lifts on a spring while its shadow drops further, spreads and softens, from flat on the page to floating.

Dependencies
None
Difficulty
Beginner
Triggers
click, state change, spring
Techniques
css transition, css variables
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A role="radiogroup" with an aria-label around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once; :focus-visible on the input outlines its option. The drawing is aria-hidden. Under prefers-reduced-motion the card changes without lifting.

More radios

See all
Firmness
12 frames a second. Steppy, like stop motion.
FPS
Ringer
Stroke