Eye Chart

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

const ROWS = [
  { value: 'huge', label: 'Huge', letters: 'E', size: 40, h: 42, tip: 96 },
  { value: 'large', label: 'Large', letters: 'FP', size: 27, h: 28, tip: 103 },
  { value: 'default', label: 'Default', letters: 'TOZ', size: 19, h: 20, tip: 105 },
  { value: 'small', label: 'Small', letters: 'LPED', size: 14, h: 15, tip: 105 },
  { value: 'tiny', label: 'Tiny', letters: 'PECFD', size: 10, h: 11, tip: 102 },
];
const PAD = 12;
const GAP = 6;
const TOPS = ROWS.reduce<number[]>((t, r, i) => [...t, i ? t[i - 1] + ROWS[i - 1].h + GAP : PAD], []);
const at = (n: number) => `.radio-eye-chart:has(.radio-eye-chart__row:nth-child(${n + 1}) input:checked)`;
const SPRING = 'linear(0, 0.111, 0.359, 0.638, 0.879, 1.047, 1.137, 1.163, 1.145, 1.105, 1.059, 1.02, 0.992, 0.978, 0.973, 0.976, 0.983, 0.99, 0.997, 1.001, 1.004, 1.004, 1.004, 1.003, 1)';

// Every row blurs by how far it is from the one in focus, like a lens racking.
const focus = ROWS.map((_, c) =>
  ROWS.map((_, k) => {
    const d = Math.abs(k - c);
    return `${at(c)} .radio-eye-chart__row:nth-child(${k + 1}) .radio-eye-chart__letters { filter: blur(${(d * 1.15).toFixed(2)}px); opacity: ${(1 - d * 0.1).toFixed(2)}; }`;
  }).join('\n  '),
).join('\n  ');

const css = `
  .radio-eye-chart { --paper: light-dark(#fbfcfd, #e5e6e9); --ink: light-dark(#373a40, #121417); display: grid; justify-items: center; gap: 10px; font-family: Georgia, 'Times New Roman', serif; }
  .radio-eye-chart__stage { position: relative; width: 230px; padding-left: 20px; }
  .radio-eye-chart__chart { position: relative; display: block; width: 150px; padding: ${PAD}px 0; border-radius: 2px; background: var(--paper); color: var(--ink); box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.2)), 0 10px 20px -8px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5)); }
  .radio-eye-chart__row { position: relative; display: flex; align-items: center; justify-content: center; margin-bottom: ${GAP}px; cursor: pointer; }
  .radio-eye-chart__row:last-of-type { margin-bottom: 0; }
  .radio-eye-chart__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
  .radio-eye-chart__letters { font-weight: 700; letter-spacing: 0.18em; margin-right: -0.18em; line-height: 1; transition: filter 0.7s cubic-bezier(0.3, 0, 0.2, 1), opacity 0.7s; }
  .radio-eye-chart__row:hover .radio-eye-chart__letters { filter: blur(0) !important; opacity: 1 !important; transition-duration: 0.25s; }
  .radio-eye-chart__row:has(input:focus-visible) .radio-eye-chart__letters { outline: 1.5px solid var(--ink); outline-offset: 3px; }
  .radio-eye-chart__num { position: absolute; left: 8px; color: color-mix(in oklab, var(--ink) 55%, var(--paper)); font: 600 6.5px system-ui, sans-serif; }
  ${focus}
  /* The line under the row in focus, and the pointer's tip, ride one spring. */
  .radio-eye-chart__line { position: absolute; left: 14px; right: 14px; top: 0; height: 1.5px; background: var(--ink); transform: translateY(var(--ly)); transition: transform 0.6s ${SPRING}; pointer-events: none; }
  .radio-eye-chart__pointer { position: absolute; left: 20px; top: 0; width: 104px; height: 4px; transform: translate(var(--px), var(--py)) rotate(-14deg); transform-origin: 0 50%; transition: transform 0.65s ${SPRING}; pointer-events: none; }
  .radio-eye-chart__stick { display: block; width: 100%; height: 100%; border-radius: 2px 3px 3px 2px; background: linear-gradient(90deg, light-dark(#373a40, #f1f2f3) 0 9px, light-dark(#5f626a, #a7abb2) 9px); box-shadow: 0 3px 5px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3)); }
  ${ROWS.map((r, i) => `${at(i)} { --ly: ${TOPS[i] + r.h + 2.5}px; --px: ${r.tip}px; --py: ${TOPS[i] + r.h / 2 - 2}px; }\n  ${at(i)} .radio-eye-chart__stick { animation: radio-eye-chart-tap${i} 0.5s ease-out 0.5s; }\n  @keyframes radio-eye-chart-tap${i} { 35% { translate: -5px 0; } 70% { translate: 1px 0; } }`).join('\n  ')}
  .radio-eye-chart__read { display: grid; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; font-style: italic; }
  .radio-eye-chart__read > span { grid-area: 1 / 1; text-align: center; opacity: 0; transform: translateY(4px); transition: opacity 0.2s, transform 0.3s; }
  .radio-eye-chart__read b { color: light-dark(#373a40, #f1f2f3); font-style: normal; }
  ${ROWS.map((_, i) => `${at(i)} .radio-eye-chart__read > :nth-child(${i + 1})`).join(', ')} { opacity: 1; transform: none; transition-delay: 0.2s; }
  @media (prefers-reduced-motion: reduce) {
    .radio-eye-chart * { animation: none !important; transition: none !important; }
  }
`;

export function Preview() {
  const name = useId();
  return (
    <>
      <style href="kit-radios-eye-chart" precedence="kit">
        {css}
      </style>
      <div className="radio-eye-chart">
        <div className="radio-eye-chart__stage">
          <div className="radio-eye-chart__chart" role="radiogroup" aria-label="Reading size">
            {ROWS.map((r, i) => (
              <label key={r.value} className="radio-eye-chart__row" style={{ height: r.h }}>
                <input className="radio-eye-chart__input" type="radio" name={name} value={r.value} aria-label={r.label} defaultChecked={i === 2} />
                <span className="radio-eye-chart__num" aria-hidden="true">
                  {i + 1}
                </span>
                <span className="radio-eye-chart__letters" style={{ fontSize: r.size }} aria-hidden="true">
                  {r.letters}
                </span>
              </label>
            ))}
            <span className="radio-eye-chart__line" aria-hidden="true" />
          </div>
          <span className="radio-eye-chart__pointer" aria-hidden="true">
            <span className="radio-eye-chart__stick" />
          </span>
        </div>
        <p className="radio-eye-chart__read" aria-hidden="true">
          {ROWS.map((r) => (
            <span key={r.value}>
              Reading size · <b>{r.label}</b>
            </span>
          ))}
        </p>
      </div>
    </>
  );
}

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

Install with shadcn

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

About this component

A reading size picker on an optician chart: the pointer moves to the row you pick, which racks into sharp focus while every other row blurs by how far it is from it.

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

A role="radiogroup" labelled "Reading size" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. Each row is a <label> whose input is named Huge, Large, Default, Small or Tiny, since the letters themselves are aria-hidden; focus outlines the row's letters. Under prefers-reduced-motion the focus changes at once.

More radios

See all
Fairground
Fortune
Glassware
Multitool