Shoji

Full on the fifteenth, rising over the east wall at dusk.
No dependencies
Code format
React + Tailwind
import { useId, useRef, useState, type CSSProperties } from 'react';

const VIEWS = [
  { label: 'Moon', body: 'Full on the fifteenth, rising over the east wall at dusk.' },
  { label: 'Maple', body: 'Red by November. The bench under it gets the last sun.' },
  { label: 'Lantern', body: 'Granite, two hundred years old, and lit for guests.' },
  { label: 'Pond', body: 'Six carp, and a heron that calls in on Tuesdays.' },
];
const BAY = 53;
// Three screens share four bays, so one bay is always open. Screen j sits left of the gap
// or right of it.
const bayOf = (j: number, gap: number) => (j < gap ? j : j + 1);
const LEAF = 'M0-5L1.4-1.6L5-2L2.4.6L3.4 4.4L0 2.4L-3.4 4.4L-2.4.6L-5-2L-1.4-1.6Z';
const LEAVES = [
  [64, 16, 0.9, 10],
  [72, 24, 1.1, -20],
  [82, 12, 0.8, 30],
  [88, 28, 1, 5],
  [96, 18, 0.9, -12],
  [78, 34, 0.8, 40],
  [70, 40, 0.7, -30],
  [92, 38, 0.8, 18],
];

const css = `
  .tabs-shoji { --shade: light-dark(#373a40, #121417); --wood: light-dark(#6d7179, #393c41); --paper: light-dark(#fbfcfd, #e5e6e9); width: 230px; font-family: Georgia, 'Times New Roman', serif; }
  .tabs-shoji__house { position: relative; height: 138px; }
  .tabs-shoji__frame { position: absolute; left: 3px; top: 20px; width: 224px; height: 116px; box-sizing: border-box; border: 6px solid var(--wood); border-radius: 2px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--shade) 40%, transparent), 0 8px 14px -8px light-dark(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.6)); overflow: hidden; }
  .tabs-shoji__scene { position: absolute; inset: 0; display: block; }
  .tabs-shoji__sky { fill: light-dark(#f3f4f7, #1a1c1f); }
  .tabs-shoji__glow { fill: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 10%, transparent); }
  .tabs-shoji__moon { fill: light-dark(#373a40, #f1f2f3); }
  .tabs-shoji__far { fill: light-dark(#ebedf0, #232529); }
  .tabs-shoji__ground { fill: light-dark(#ebedf0, #232529); }
  .tabs-shoji__ink { fill: light-dark(#cbced3, #393c41); }
  .tabs-shoji__branch { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 2; stroke-linecap: round; }
  .tabs-shoji__leaf { fill: light-dark(#6d7179, #7d8087); }
  .tabs-shoji__light { fill: light-dark(#373a40, #f1f2f3); }
  .tabs-shoji__water { fill: light-dark(#dbdde1, #2e3035); }
  .tabs-shoji__ripple { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 0.8; }
  .tabs-shoji__fish { fill: light-dark(#50535b, #c6c8cd); }
  /* A paper screen in a lattice, sliding on its track; the garden shows softly through. */
  .tabs-shoji__screen { position: absolute; top: 0; left: 0; width: ${BAY + 1}px; height: 100%; box-sizing: border-box; background: repeating-linear-gradient(90deg, transparent 0 15.5px, var(--wood) 15.5px 17px) 2px 0, repeating-linear-gradient(transparent 0 24px, var(--wood) 24px 25.5px) 0 2px, color-mix(in oklab, var(--paper) 78%, transparent); -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); box-shadow: inset 0 0 0 3px var(--wood), -2px 0 4px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); transition: transform 0.5s cubic-bezier(0.5, 0, 0.2, 1.08) var(--d, 0s); }
  .tabs-shoji__screen::after { content: ''; position: absolute; top: 50%; right: 6px; width: 5px; height: 9px; margin-top: -4px; border-radius: 3px; background: color-mix(in oklab, var(--wood) 70%, var(--shade)); }
  .tabs-shoji__bay { position: absolute; top: 0; width: ${BAY}px; height: 136px; padding: 0; border: 0; background: none; cursor: pointer; }
  .tabs-shoji__bay span { position: absolute; left: 0; right: 0; top: 0; color: light-dark(#5f626a, #a7abb2); font-size: 11.5px; letter-spacing: 0.04em; transition: color 0.3s; }
  .tabs-shoji__bay:hover span { color: light-dark(#42454c, #e5e6e9); }
  .tabs-shoji__bay[aria-selected='true'] span { color: light-dark(#373a40, #f1f2f3); }
  .tabs-shoji__bay:focus-visible { outline: none; }
  .tabs-shoji__bay:focus-visible span { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; border-radius: 3px; }
  .tabs-shoji__panel { min-height: 3em; margin-top: 10px; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; line-height: 1.5; }
  @media (prefers-reduced-motion: reduce) {
    .tabs-shoji__screen { transition: none; }
  }
`;

export function Preview() {
  const id = useId();
  const [{ sel, from }, setState] = useState({ sel: 0, from: 0 });
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const pick = (i: number) => setState((s) => (s.sel === i ? s : { sel: i, from: s.sel }));
  const n = VIEWS.length;
  const onKeyDown = (e: React.KeyboardEvent) => {
    const next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
    if (next === undefined) return;
    e.preventDefault();
    pick(next);
    tabs.current[next]?.focus();
  };
  // The screen next to the old gap slides first, then the next, like pushing them along.
  const delay = (j: number) => (sel > from ? j - from : from - 1 - j) * 0.11;
  return (
    <>
      <style href="kit-tabs-shoji" precedence="kit">
        {css}
      </style>
      <div className="tabs-shoji">
        <div className="tabs-shoji__house">
          <div className="tabs-shoji__frame" aria-hidden="true">
            <svg className="tabs-shoji__scene" width="212" height="104" viewBox="0 0 212 104">
              <rect className="tabs-shoji__sky" width="212" height="104" />
              <circle className="tabs-shoji__glow" cx="26" cy="32" r="24" />
              <circle className="tabs-shoji__moon" cx="26" cy="32" r="12" />
              <path className="tabs-shoji__far" d="M0 76Q30 62 58 70T120 66T180 70T212 64V104H0Z" />
              <path className="tabs-shoji__branch" d="M106 4Q90 10 80 22T60 30M88 15Q92 26 90 36M80 22Q74 30 70 42" />
              {LEAVES.map(([x, y, s, r]) => (
                <path key={`${x}-${y}`} className="tabs-shoji__leaf" d={LEAF} transform={`translate(${x} ${y}) rotate(${r}) scale(${s * 1.3})`} />
              ))}
              <path className="tabs-shoji__ground" d="M0 84H212V104H0Z" />
              <g className="tabs-shoji__ink">
                <path d="M120 84h22l-3-5h-16zM127 79h8V64h-8zM121 64h20l-2-3h-16zM118 46h26l-4 5h-18zM129 41h4v-4h-4z" />
                <path d="M118 46Q131 36 144 46Z" />
                <rect x="122" y="51" width="18" height="10" />
              </g>
              <rect className="tabs-shoji__light" x="127" y="52.5" width="8" height="7" rx="1" />
              <ellipse className="tabs-shoji__water" cx="186" cy="92" rx="26" ry="8" />
              <ellipse className="tabs-shoji__ripple" cx="178" cy="91" rx="8" ry="2.4" />
              <ellipse className="tabs-shoji__ripple" cx="178" cy="91" rx="13" ry="4" />
              <path className="tabs-shoji__fish" d="M188 93q6-4 12-1q-6 3-12 1zM200 92l4-2.5v5z" />
              <path className="tabs-shoji__branch" d="M166 86V62M170 86V58M174 86V66" />
            </svg>
            {[0, 1, 2].map((j) => (
              <span key={j} className="tabs-shoji__screen" style={{ transform: `translateX(${bayOf(j, sel) * BAY - (bayOf(j, sel) === 3 ? 1 : 0)}px)`, '--d': `${bayOf(j, sel) !== bayOf(j, from) ? delay(j) : 0}s` } as CSSProperties} />
            ))}
          </div>
          <div role="tablist" aria-label="Views" onKeyDown={onKeyDown}>
            {VIEWS.map((v, i) => (
              <button
                key={v.label}
                ref={(el) => {
                  tabs.current[i] = el;
                }}
                className="tabs-shoji__bay"
                type="button"
                role="tab"
                id={`${id}-tab-${i}`}
                aria-selected={sel === i}
                aria-controls={`${id}-panel-${i}`}
                tabIndex={sel === i ? 0 : -1}
                style={{ left: 9 + i * BAY }}
                onClick={() => pick(i)}
              >
                <span>{v.label}</span>
              </button>
            ))}
          </div>
        </div>
        {VIEWS.map((v, i) => (
          <div key={v.label} className="tabs-shoji__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
            {v.body}
          </div>
        ))}
      </div>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/tab-shoji
Source on GitHub

About this component

Sliding paper screens over a garden: pick a view and the screens slide one after another to open that bay, the scene showing softly through the paper.

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

Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. The drawing is aria-hidden. Under prefers-reduced-motion the screens are simply in their new places.

More tabs

See all
pH 7.9 and cloudy, with algae and a little silt.
Test Tubes
Drawing 1.2 kW from the grid, on the off-peak rate until seven.
Arc
Grasslands. Eight levels, a hidden pipe in the fourth.
Blocks
Tape hiss, a count-in and the first chord. Forty seconds long.
Cassette