Floppy

A:\PHOTOSBEACH.JPG412KSNOW.JPG506K
No dependencies
Code format
React + Tailwind
import { useId, useRef, useState } from 'react';

const DISKS = [
  { label: 'Photos', dir: 'PHOTOS', files: [['BEACH.JPG', '412K'], ['SNOW.JPG', '506K']] },
  { label: 'Letters', dir: 'LETTERS', files: [['MUM.TXT', '3K'], ['LANDLORD.TXT', '2K']] },
  { label: 'Games', dir: 'GAMES', files: [['TETRA.EXE', '188K'], ['CHESS.EXE', '240K']] },
];
const REST_LEFT = [20, 89, 158];
const SLOT_LEFT = 89;

const css = `
  .tabs-floppy { --shade: light-dark(#373a40, #121417); --plastic: light-dark(#cbced3, #2e3035); --paper: light-dark(#fbfcfd, #e5e6e9); --ink: light-dark(#373a40, #121417); --steel: light-dark(#6d7179, #a7abb2); width: 230px; }
  .tabs-floppy__desk { position: relative; height: 154px; }
  /* The drive seen from above: vents, a badge, and the slot along its front edge. */
  .tabs-floppy__drive { position: absolute; left: 30px; top: 0; z-index: 2; width: 170px; height: 58px; border-radius: 6px 6px 3px 3px; background: repeating-linear-gradient(90deg, color-mix(in oklab, var(--shade) 40%, transparent) 0 2px, transparent 2px 6px) 12px 10px / 60px 18px no-repeat, linear-gradient(light-dark(#dbdde1, #2e3035), light-dark(#ebedf0, #232529)); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 14%, transparent), 0 6px 12px -6px light-dark(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7)); }
  .tabs-floppy__badge { position: absolute; right: 12px; top: 10px; color: light-dark(#5f626a, #a7abb2); font: 600 7.5px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.08em; text-align: right; }
  .tabs-floppy__bezel { position: absolute; left: 0; right: 0; bottom: 0; height: 12px; border-radius: 0 0 3px 3px; background: linear-gradient(light-dark(#cbced3, #393c41), light-dark(#dbdde1, #2e3035)); }
  .tabs-floppy__slot { position: absolute; left: 54px; top: 4px; width: 62px; height: 3px; border-radius: 2px; background: var(--shade); }
  .tabs-floppy__led { position: absolute; left: 126px; top: 4px; width: 7px; height: 3px; border-radius: 1px; background: light-dark(#ebedf0, #232529); }
  .tabs-floppy[data-run='a'] .tabs-floppy__led { animation: tabs-floppy-read-a 1.1s steps(1) 1.05s; }
  .tabs-floppy[data-run='b'] .tabs-floppy__led { animation: tabs-floppy-read-b 1.1s steps(1) 1.05s; }
  .tabs-floppy__eject { position: absolute; left: 140px; top: 3px; width: 18px; height: 5px; border-radius: 2px; background: light-dark(#6d7179, #7d8087); box-shadow: 0 1px 0 light-dark(#ebedf0, #232529); }
  .tabs-floppy[data-run='a'] .tabs-floppy__eject { animation: tabs-floppy-press-a 0.3s; }
  .tabs-floppy[data-run='b'] .tabs-floppy__eject { animation: tabs-floppy-press-b 0.3s; }
  .tabs-floppy__spot { position: absolute; top: 96px; width: 52px; height: 56px; box-sizing: border-box; border: 1px dashed light-dark(#dbdde1, #2e3035); border-radius: 4px; }
  /* Each disk moves in two steps: picked up and carried under the slot, then pushed in;
     or popped out, then carried home and set down. */
  .tabs-floppy__track { position: absolute; left: 0; top: 96px; transition: transform 0.45s cubic-bezier(0.45, 0, 0.3, 1) 0.24s; }
  .tabs-floppy__track[data-in] { z-index: 1; transition: transform 0.42s cubic-bezier(0.45, 0, 0.3, 1) 0.3s; }
  .tabs-floppy__lift { display: block; }
  .tabs-floppy__track[data-in][data-carry] .tabs-floppy__lift { animation: tabs-floppy-carry-in 0.5s ease-in-out 0.3s both; }
  .tabs-floppy__track[data-out] .tabs-floppy__lift { animation: tabs-floppy-carry-out 0.75s ease-in-out both; }
  .tabs-floppy__disk { position: relative; display: block; width: 52px; height: 56px; padding: 0; border: 0; border-radius: 3px; background: linear-gradient(160deg, color-mix(in oklab, var(--plastic) 85%, light-dark(#373a40, #f1f2f3)), var(--plastic) 45%, color-mix(in oklab, var(--plastic) 80%, var(--shade))); clip-path: polygon(0 0, 46px 0, 52px 6px, 52px 56px, 0 56px); cursor: pointer; transition: transform 0.3s cubic-bezier(0.3, 1.5, 0.5, 1); }
  .tabs-floppy__track[data-in] .tabs-floppy__disk { transform: translateY(-32px); transition: transform 0.32s cubic-bezier(0.5, 0, 0.35, 1.3) 0.76s; }
  .tabs-floppy__disk:hover { filter: brightness(1.08); }
  .tabs-floppy__track:not([data-in]) .tabs-floppy__disk:hover { transform: translateY(-3px); }
  .tabs-floppy__shutter { position: absolute; left: 13px; top: 0; width: 26px; height: 19px; border-radius: 0 0 2px 2px; background: linear-gradient(90deg, var(--steel), color-mix(in oklab, var(--steel) 60%, light-dark(#373a40, #f1f2f3)) 40%, var(--steel)); }
  .tabs-floppy__shutter::after { content: ''; position: absolute; right: 5px; top: 3px; width: 6px; height: 12px; border-radius: 1px; background: color-mix(in oklab, var(--plastic) 70%, var(--shade)); }
  .tabs-floppy__arrow { position: absolute; left: 4px; top: 4px; width: 0; height: 0; border: 3px solid transparent; border-bottom: 4px solid color-mix(in oklab, var(--plastic) 60%, light-dark(#373a40, #f1f2f3)); border-top: 0; }
  .tabs-floppy__label { position: absolute; left: 5px; right: 5px; bottom: 4px; height: 24px; border-radius: 1px; background: repeating-linear-gradient(transparent 0 7px, color-mix(in oklab, var(--ink) 14%, transparent) 7px 8px) 0 6px / 100% 16px no-repeat, var(--paper); color: var(--ink); font: 13px/24px 'Bradley Hand', 'Segoe Print', 'Chalkboard SE', 'Comic Sans MS', cursive; text-align: center; }
  .tabs-floppy__disk:focus-visible { outline: none; }
  .tabs-floppy__track:has(.tabs-floppy__disk:focus-visible) { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; border-radius: 4px; }
  .tabs-floppy__track[data-in]:has(.tabs-floppy__disk:focus-visible) { outline: none; }
  .tabs-floppy__track[data-in] .tabs-floppy__disk:focus-visible .tabs-floppy__label { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  /* The listing, line by line once the drive has read the disk. */
  .tabs-floppy__panel { min-height: 44px; margin-top: 8px; padding: 0 2px; color: light-dark(#50535b, #c6c8cd); font: 10.5px/15px ui-monospace, SFMono-Regular, Menlo, monospace; }
  .tabs-floppy__panel span { display: grid; grid-template-columns: 92px 34px; animation: tabs-floppy-line 0.2s steps(1) both; }
  .tabs-floppy__panel span:first-child { display: block; color: light-dark(#373a40, #f1f2f3); }
  .tabs-floppy__panel b, .tabs-floppy__panel i { font: inherit; }
  .tabs-floppy__panel i { text-align: right; }
  @keyframes tabs-floppy-read-a { 0%, 30%, 60% { background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 6px light-dark(#373a40, #f1f2f3); } 15%, 45%, 80% { background: light-dark(#ebedf0, #232529); box-shadow: none; } }
  @keyframes tabs-floppy-read-b { 0%, 30%, 60% { background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 6px light-dark(#373a40, #f1f2f3); } 15%, 45%, 80% { background: light-dark(#ebedf0, #232529); box-shadow: none; } }
  @keyframes tabs-floppy-press-a { 40% { transform: translateY(1px); box-shadow: none; } }
  @keyframes tabs-floppy-press-b { 40% { transform: translateY(1px); box-shadow: none; } }
  @keyframes tabs-floppy-carry-in { 0%, 100% { transform: none; filter: none; } 35%, 80% { transform: scale(1.06); filter: drop-shadow(0 6px 6px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); } }
  @keyframes tabs-floppy-carry-out { 0%, 100% { transform: none; filter: none; } 30%, 85% { transform: scale(1.06); filter: drop-shadow(0 6px 6px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); } }
  @keyframes tabs-floppy-line { from { opacity: 0; } to { opacity: 1; } }
  @media (prefers-reduced-motion: reduce) {
    .tabs-floppy__track, .tabs-floppy__track[data-in], .tabs-floppy__disk, .tabs-floppy__track[data-in] .tabs-floppy__disk { transition: none; }
    .tabs-floppy__led, .tabs-floppy__eject, .tabs-floppy__panel span, .tabs-floppy__lift { animation: none !important; }
  }
`;

export function Preview() {
  const id = useId();
  const [{ sel, from, run }, setState] = useState({ sel: 0, from: -1, run: 'a' });
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const pick = (i: number) => setState((s) => (s.sel === i ? s : { sel: i, from: s.sel, run: s.run === 'a' ? 'b' : 'a' }));
  const n = DISKS.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();
  };
  return (
    <>
      <style href="kit-tabs-floppy" precedence="kit">
        {css}
      </style>
      <div className="tabs-floppy" data-run={run}>
        <div className="tabs-floppy__desk">
          <div className="tabs-floppy__drive" aria-hidden="true">
            <span className="tabs-floppy__badge">
              3.5 IN
              <br />
              1.44 MB
            </span>
            <span className="tabs-floppy__bezel">
              <span className="tabs-floppy__slot" />
              <span className="tabs-floppy__led" />
              <span className="tabs-floppy__eject" />
            </span>
          </div>
          {REST_LEFT.map((x) => (
            <span key={x} className="tabs-floppy__spot" style={{ left: x }} aria-hidden="true" />
          ))}
          <div role="tablist" aria-label="Disks" onKeyDown={onKeyDown}>
            {DISKS.map((d, i) => (
              <span
                key={d.label}
                className="tabs-floppy__track"
                data-in={sel === i ? '' : undefined}
                data-carry={sel === i && from >= 0 ? '' : undefined}
                data-out={from === i && sel !== i ? '' : undefined}
                style={{ transform: sel === i ? `translate(${SLOT_LEFT}px, -36px)` : `translateX(${REST_LEFT[i]}px)` }}
              >
                <span className="tabs-floppy__lift">
                <button
                  ref={(el) => {
                    tabs.current[i] = el;
                  }}
                  className="tabs-floppy__disk"
                  type="button"
                  role="tab"
                  id={`${id}-tab-${i}`}
                  aria-selected={sel === i}
                  aria-controls={`${id}-panel-${i}`}
                  tabIndex={sel === i ? 0 : -1}
                  onClick={() => pick(i)}
                >
                  <span className="tabs-floppy__shutter" aria-hidden="true" />
                  <span className="tabs-floppy__arrow" aria-hidden="true" />
                  <span className="tabs-floppy__label">{d.label}</span>
                </button>
                </span>
              </span>
            ))}
          </div>
        </div>
        {DISKS.map((d, i) => (
          <div key={d.label} className="tabs-floppy__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
            <span style={{ animationDelay: '1.15s' }}>{`A:\\${d.dir}`}</span>
            {d.files.map(([name, size], k) => (
              <span key={name} style={{ animationDelay: `${1.35 + k * 0.18}s` }}>
                <b>{name}</b>
                <i>{size}</i>
              </span>
            ))}
          </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-floppy
Source on GitHub

About this component

Disks as tabs: the disk in the drive ejects and goes back to its place, the one you pick slides into the slot, the light blinks and its files list out.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, delay stagger
Techniques
css transition, keyframes, clip path, stagger
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 disks swap places at once and the light and the listing do not animate.

More tabs

See all
Say it first.
Fortune Teller
Wool, double-breasted and fully lined. Sizes S to XL.
Hangers
Ladder
A 7 cm blade that locks open, for parcels, rope and apples.
Multitool