Specimen

Papilio machaonSwallowtail, plate IV
No dependencies
Code format
React + Tailwind
import { useEffect, useRef, useState } from 'react';

const css = `
  .card-specimen { flex: none; --mat: light-dark(#fbfcfd, #e5e6e9); --print: light-dark(#373a40, #121417); --wing: light-dark(#cbced3, #7d8087); --wash: light-dark(#ebedf0, #c6c8cd); --flap: 0deg; position: relative; box-sizing: border-box; width: 196px; height: 226px; padding: 7px; border-radius: 3px; background: linear-gradient(135deg, light-dark(#5f626a, #a7abb2), light-dark(#cbced3, #393c41)); box-shadow: 0 10px 20px -8px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5)); color: var(--print); }
  .card-specimen__case { position: relative; display: block; height: 100%; overflow: visible; border-radius: 1px; background: var(--mat); box-shadow: inset 0 2px 7px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.28)); }
  .card-specimen__bug { position: absolute; left: 50%; top: 18px; width: 0; height: 0; z-index: 3; }
  .card-specimen__bug[data-flying='out'] { animation: card-specimen-out 2s cubic-bezier(0.4, 0, 0.7, 1) forwards; }
  .card-specimen__bug[data-flying='in'] { animation: card-specimen-in 1.9s cubic-bezier(0.2, 0.5, 0.4, 1) forwards; }
  @keyframes card-specimen-out {
    0% { transform: none; }
    16% { transform: translate(6px, -14px) rotate(8deg) scale(1.02); }
    34% { transform: translate(-34px, -34px) rotate(-16deg) scale(0.92); }
    52% { transform: translate(26px, -12px) rotate(20deg) scale(0.84); }
    72% { transform: translate(60px, -70px) rotate(34deg) scale(0.66); }
    100% { transform: translate(180px, -160px) rotate(52deg) scale(0.42); opacity: 0.7; }
  }
  @keyframes card-specimen-in {
    0% { transform: translate(-170px, -130px) rotate(-40deg) scale(0.42); }
    44% { transform: translate(-44px, -56px) rotate(-22deg) scale(0.74); }
    70% { transform: translate(24px, -30px) rotate(14deg) scale(0.92); }
    88% { transform: translate(-5px, -8px) rotate(-5deg) scale(1.02); }
    100% { transform: none; }
  }
  .card-specimen__wing { position: absolute; top: 0; width: 80px; height: 112px; overflow: visible; filter: drop-shadow(2px 4px 2px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.28))); transition: transform 0.4s cubic-bezier(0.3, 1.3, 0.5, 1); }
  .card-specimen__wing--r { left: 1px; transform-origin: 0 50%; transform: perspective(300px) rotateY(var(--flap)); }
  .card-specimen__wing--l { right: 1px; transform-origin: 100% 50%; transform: perspective(300px) rotateY(calc(var(--flap) * -1)) scaleX(1); }
  .card-specimen__wing--l svg { transform: scaleX(-1); }
  .card-specimen__wing svg { display: block; width: 100%; height: 100%; overflow: visible; }
  .card-specimen__bug[data-flying] .card-specimen__wing { transition: none; animation: card-specimen-beat 0.15s ease-in-out infinite alternate; }
  .card-specimen__bug[data-flying] .card-specimen__wing--r { --way: 1; }
  .card-specimen__bug[data-flying] .card-specimen__wing--l { --way: -1; }
  @keyframes card-specimen-beat {
    from { transform: perspective(300px) rotateY(calc(var(--way) * 8deg)); }
    to { transform: perspective(300px) rotateY(calc(var(--way) * 70deg)); }
  }
  .card-specimen__fill { fill: var(--wing); }
  .card-specimen__wash { fill: var(--wash); }
  .card-specimen__band { fill: var(--print); }
  .card-specimen__spot { fill: var(--mat); }
  .card-specimen__vein { fill: none; stroke: var(--print); stroke-width: 0.8; opacity: 0.72; stroke-linecap: round; }
  .card-specimen__body { position: absolute; left: -4px; top: 18px; width: 8px; height: 74px; overflow: visible; }
  .card-specimen__ink { fill: var(--print); }
  .card-specimen__antenna { fill: none; stroke: var(--print); stroke-width: 0.8; stroke-linecap: round; }
  .card-specimen__pin { position: absolute; left: 50%; top: 42px; width: 8px; height: 8px; margin-left: -4px; border-radius: 50%; background: radial-gradient(circle at 34% 30%, var(--mat), light-dark(#6d7179, #7d8087) 60%); box-shadow: 1px 3px 2px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); z-index: 4; transition: transform 0.4s cubic-bezier(0.5, 0, 0.8, 0.5), opacity 0.4s; }
  .card-specimen[data-free='true'] .card-specimen__pin { transform: translate(44px, 86px) rotate(70deg); }
  .card-specimen__ghost { position: absolute; left: 50%; top: 42px; width: 3px; height: 3px; margin: 3px 0 0 -1.5px; border-radius: 50%; background: var(--print); opacity: 0; transition: opacity 0.3s 0.3s; }
  .card-specimen[data-free='true'] .card-specimen__ghost { opacity: 0.7; }
  .card-specimen__dust { position: absolute; left: 50%; top: 18px; width: 148px; height: 112px; margin-left: -74px; background: radial-gradient(ellipse 40% 34% at 28% 34%, color-mix(in oklab, var(--print) 13%, transparent), transparent), radial-gradient(ellipse 40% 34% at 72% 34%, color-mix(in oklab, var(--print) 13%, transparent), transparent), radial-gradient(ellipse 26% 28% at 36% 74%, color-mix(in oklab, var(--print) 10%, transparent), transparent), radial-gradient(ellipse 26% 28% at 64% 74%, color-mix(in oklab, var(--print) 10%, transparent), transparent); opacity: 0; transition: opacity 0.8s 0.3s; }
  .card-specimen[data-free='true'] .card-specimen__dust { opacity: 1; }
  .card-specimen__label { position: absolute; left: 14px; right: 14px; bottom: 9px; padding: 6px 8px 5px; border: 1px solid color-mix(in oklab, var(--print) 50%, transparent); background: var(--mat); text-align: center; }
  .card-specimen__latin { display: block; font: italic 700 13px/1.1 Georgia, 'Times New Roman', serif; }
  .card-specimen[data-free='true'] .card-specimen__latin { text-decoration: line-through; }
  .card-specimen__plate { display: block; margin-top: 2px; font: 9px/1.2 Georgia, 'Times New Roman', serif; letter-spacing: 0.02em; }
  .card-specimen__act { margin-top: 3px; padding: 1px 2px; border: 0; background: none; color: inherit; font: italic 700 10.5px/1.3 Georgia, 'Times New Roman', serif; text-decoration: underline; text-underline-offset: 2px; cursor: pointer; }
  .card-specimen__act:focus-visible { outline: 2px solid var(--print); outline-offset: 1px; }
  @media (prefers-reduced-motion: reduce) {
    .card-specimen__bug[data-flying] { animation-duration: 0.01s; }
    .card-specimen__bug[data-flying] .card-specimen__wing { animation: none; }
    .card-specimen__wing, .card-specimen__pin { transition: none; }
  }
`;

function Wing() {
  return (
    <svg viewBox="0 0 80 112" aria-hidden="true">
      {/* Hindwing first, so the forewing overlaps it as on the real thing. */}
      <path className="card-specimen__fill" d="M0 50 C14 50 40 50 52 62 C60 74 54 92 44 98 C42 106 34 110 30 102 C20 96 6 78 0 60 Z" />
      <path className="card-specimen__band" d="M50 60 C60 74 54 92 44 98 C42 106 34 110 30 102 C28 100 26 98 24 95 C34 96 44 84 42 66 Z" />
      <circle className="card-specimen__spot" cx="48" cy="72" r="2.3" />
      <circle className="card-specimen__spot" cx="46.5" cy="82" r="2.3" />
      <circle className="card-specimen__spot" cx="40.5" cy="91" r="2.2" />
      <circle className="card-specimen__spot" cx="33" cy="99" r="2.1" />
      <circle className="card-specimen__band" cx="20" cy="74" r="6.2" />
      <circle className="card-specimen__wash" cx="20" cy="74" r="3.8" />
      <circle className="card-specimen__band" cx="20" cy="74" r="1.7" />
      <path className="card-specimen__vein" d="M1 54 C14 58 30 58 42 64 M1 56 C10 66 20 78 24 92 M1 58 C4 68 10 80 16 88" />
      <path className="card-specimen__fill" d="M0 32 C8 8 44 -3 78 7 C80 22 68 44 44 51 C28 55 10 50 0 46 Z" />
      <path className="card-specimen__band" d="M78 7 C80 22 68 44 44 51 C42 52 40 52 38 53 C54 40 64 26 62 6 C68 5 73 5.6 78 7 Z" />
      <path className="card-specimen__wash" d="M6 28 C18 14 34 8 48 6 C38 16 26 26 8 36 Z" />
      <circle className="card-specimen__spot" cx="70" cy="13" r="2.2" />
      <circle className="card-specimen__spot" cx="68" cy="24" r="2.2" />
      <circle className="card-specimen__spot" cx="60" cy="34" r="2.1" />
      <circle className="card-specimen__spot" cx="50" cy="43" r="2" />
      <path className="card-specimen__vein" d="M1 34 C16 20 36 12 60 8 M1 38 C18 30 40 24 62 20 M1 42 C20 40 40 38 56 32 M1 45 C14 46 30 46 44 44" />
    </svg>
  );
}

export function SpecimenCard() {
  const root = useRef<HTMLElement>(null);
  const [state, setState] = useState<'pinned' | 'leaving' | 'free' | 'landing'>('pinned');
  const stateRef = useRef(state);
  stateRef.current = state;
  const timer = useRef(0);

  // Wings stir as you come close: the nearer the cursor, the further they lift.
  useEffect(() => {
    const el = root.current;
    if (!el || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const stir = (e: PointerEvent) => {
      if (stateRef.current !== 'pinned') return;
      const b = el.getBoundingClientRect();
      const d = Math.hypot(e.clientX - (b.left + b.width / 2), e.clientY - (b.top + 70));
      el.style.setProperty('--flap', `${(Math.max(0, 1 - d / 150) * 42).toFixed(1)}deg`);
    };
    const rest = () => el.style.setProperty('--flap', '0deg');
    el.addEventListener('pointermove', stir);
    el.addEventListener('pointerleave', rest);
    return () => {
      window.clearTimeout(timer.current);
      el.removeEventListener('pointermove', stir);
      el.removeEventListener('pointerleave', rest);
    };
  }, []);

  const act = () => {
    window.clearTimeout(timer.current);
    root.current?.style.setProperty('--flap', '0deg');
    if (state === 'pinned') {
      setState('leaving');
      timer.current = window.setTimeout(() => setState('free'), 2000);
    } else if (state === 'free') {
      setState('landing');
      timer.current = window.setTimeout(() => setState('pinned'), 1900);
    }
  };

  const gone = state === 'free';
  return (
    <>
      <style>
        {css}
      </style>
      <article ref={root} className="card-specimen" data-free={state === 'leaving' || state === 'free'}>
        <div className="card-specimen__case">
          <span className="card-specimen__dust" aria-hidden="true" />
          <span className="card-specimen__ghost" aria-hidden="true" />
          {!gone && (
            <div className="card-specimen__bug" data-flying={state === 'leaving' ? 'out' : state === 'landing' ? 'in' : undefined} aria-hidden="true">
              <span className="card-specimen__wing card-specimen__wing--r">
                <Wing />
              </span>
              <span className="card-specimen__wing card-specimen__wing--l">
                <Wing />
              </span>
              <svg className="card-specimen__body" viewBox="0 0 8 74">
                <path className="card-specimen__antenna" d="M3 8 C0 2 -6 -6 -12 -11 M5 8 C8 2 14 -6 20 -11" />
                <circle className="card-specimen__ink" cx="-12" cy="-11" r="1.2" />
                <circle className="card-specimen__ink" cx="20" cy="-11" r="1.2" />
                <circle className="card-specimen__ink" cx="4" cy="9" r="3.3" />
                <path className="card-specimen__ink" d="M4 11 C7.6 16 7.6 28 6.4 40 C5.6 52 5 64 4 72 C3 64 2.4 52 1.6 40 C0.4 28 0.4 16 4 11 Z" />
              </svg>
            </div>
          )}
          <span className="card-specimen__pin" aria-hidden="true" />
          <div className="card-specimen__label">
            <span className="card-specimen__latin">Papilio machaon</span>
            <span className="card-specimen__plate">Swallowtail, plate IV</span>
            <button type="button" className="card-specimen__act" onClick={act} disabled={state === 'leaving' || state === 'landing'}>
              {state === 'pinned' || state === 'leaving' ? 'Release it' : 'Call it back'}
            </button>
          </div>
        </div>
      </article>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/card-specimen
Source on GitHub

About this component

A collection item as a butterfly pinned in a museum case with its Latin label. Its wings stir as you come near; release it and it flaps off the page, leaving just the pin.

Dependencies
None
Difficulty
Intermediate
Triggers
card, click, mouse tracking, 3d
Techniques
keyframes, perspective, event position, state machine, svg
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

An article with a real Release button whose label follows the state; the butterfly is aria-hidden. Under prefers-reduced-motion the wings do not beat or stir and the butterfly moves at once.

More cards

See all
Zoetrope

Yes. Your plan runs to the end of the period you paid for, then stops.

Accordion

Inbox

  • Ana Ruiz

    The prints are in, come by after 4

  • Jun Bae

    Proofs attached, two small fixes

Actions
Carousel