Design gallery
Motion and effects

Isometric stack

Three screenshots fanned out as a tilted, exploded stack that settles flat as it scrolls into view. Scroll inside the box to drive it.

Scroll down to settle the stack

Code 3 files

src/gallery/iso-stack/IsoStack.astroAstro92 lines
---
// Three screenshots as an isometric, exploded stack that settles flat as it scrolls
// into view (--p goes 0 to 1). Scroll inside the box to drive it.
// In a grid card (inside [data-preview]) it tilts open and settles on a loop instead.
// Reduced motion and no JavaScript get the flat state.
// On a real page, measure against the window instead of the scroll box.
import Frame from '../browser-frame/Frame.astro';
---
<div class="isodemo" data-iso-demo>
  <div class="scroller" tabindex="0" aria-label="Isometric stack demo. Scroll to flatten the stack.">
    <p class="lead">Scroll down to settle the stack</p>
    <div class="iso" data-iso>
      <div class="plane">
        <div class="layer b2"><Frame tone="#c96f4a" /></div>
        <div class="layer b1"><Frame tone="#6b7fa8" /></div>
        <div class="layer front"><Frame tone="#3b6e5b" /></div>
      </div>
    </div>
    <div class="tail"></div>
  </div>
</div>

<style>
  .isodemo { position: absolute; inset: 0; container-type: size; color: #b4b5af; }
  .scroller { height: 100%; overflow-y: auto; scrollbar-width: thin; display: flex; flex-direction: column; align-items: center; }
  .lead { flex: 0 0 70cqh; display: flex; align-items: flex-end; padding-bottom: 2rem; margin: 0; font-size: 0.9375rem; }
  .tail { flex: 0 0 45cqh; }
  .iso { --p: 1; flex: none; width: min(62cqw, 100cqh); perspective: 2400px; }
  .plane {
    --q: calc(1 - var(--p));
    position: relative; transform-style: preserve-3d;
    transform: rotateX(calc(var(--q) * 52deg)) rotateZ(calc(var(--q) * -38deg)) scale(calc(0.86 + var(--p) * 0.14));
  }
  .layer { transform-style: preserve-3d; }
  .front { position: relative; transform: translateZ(calc(var(--q) * 120px)); }
  .b1, .b2 { position: absolute; inset: 0; }
  /* Exploded below the front card while tilted; fanned slightly behind it once flat. */
  .b1 { transform: translateZ(calc(var(--q) * 20px)) translate(calc(var(--p) * 3.5%), calc(var(--p) * 5%)); opacity: calc(0.55 + var(--q) * 0.45); }
  .b2 { transform: translateZ(calc(var(--q) * -80px)) translate(calc(var(--p) * 7%), calc(var(--p) * 10%)); opacity: calc(0.3 + var(--q) * 0.7); }

  /* In a grid card it plays itself, so there is nothing to scroll. */
  .auto .scroller { overflow: hidden; justify-content: center; }
  .auto .lead, .auto .tail { display: none; }
</style>

<script>
  import { playsItself, whenFree } from '../../lib/preview';

  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  const ease = (x: number) => 1 - Math.pow(1 - x, 3);

  // Scroll mode: 0 while the stack enters at the bottom, 1 once its middle reaches the middle of the box.
  function byScroll(scroller: HTMLElement, iso: HTMLElement) {
    let ticking = false;
    const update = () => {
      ticking = false;
      const s = scroller.getBoundingClientRect(), r = iso.getBoundingClientRect();
      const raw = (s.height - (r.top - s.top)) / (s.height * 0.5 + r.height * 0.5);
      iso.style.setProperty('--p', ease(Math.min(1, Math.max(0, raw))).toFixed(4));
    };
    scroller.addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(update); } }, { passive: true });
    new ResizeObserver(update).observe(scroller);
    update();
  }

  // Card mode: open, settle, hold, open again. Stops off screen and while someone reaches for it.
  function byLoop(root: HTMLElement, iso: HTMLElement) {
    root.classList.add('auto');
    const HOLD = 1400, MOVE = 1800;
    const cycle = HOLD * 2 + MOVE * 2;
    let raf = 0, start = 0, paused = 0;
    const tick = (t: number) => {
      const k = (t - start) % cycle;
      const p = k < HOLD ? 0 : k < HOLD + MOVE ? ease((k - HOLD) / MOVE)
        : k < HOLD * 2 + MOVE ? 1 : 1 - ease((k - HOLD * 2 - MOVE) / MOVE);
      iso.style.setProperty('--p', p.toFixed(4));
      raf = requestAnimationFrame(tick);
    };
    iso.style.setProperty('--p', '0');
    whenFree(root, (free) => {
      if (free) { start = performance.now() - paused; raf = requestAnimationFrame(tick); }
      else { cancelAnimationFrame(raf); paused = (performance.now() - start) % cycle; }
    });
  }

  document.querySelectorAll<HTMLElement>('[data-iso-demo]').forEach((root) => {
    const scroller = root.querySelector<HTMLElement>('.scroller');
    const iso = root.querySelector<HTMLElement>('[data-iso]');
    if (!scroller || !iso || reduce) return;
    playsItself(root) ? byLoop(root, iso) : byScroll(scroller, iso);
  });
</script>
src/gallery/browser-frame/Frame.astroAstro54 lines
---
// A browser window at one fixed ratio, so screenshots never crop or sit at mixed sizes.
// The view holds a drawn placeholder page here; on a real site put an <img> in .view
// (object-fit: cover, object-position: top) sized to the capture's ratio.
interface Props { host?: string; tone?: string }
const { host = 'example.com', tone = '#3b6e5b' } = Astro.props;
---
<figure class="shot" style={`--tone: ${tone}`}>
  <div class="bar" aria-hidden="true">
    <span class="dots"><i></i><i></i><i></i></span>
    <span class="host">{host}</span>
  </div>
  <div class="view" aria-hidden="true">
    <div class="page">
      <span class="nav"><i></i><i></i><i></i></span>
      <span class="head"></span>
      <span class="line"></span>
      <span class="line short"></span>
      <span class="cta"></span>
      <span class="tile"></span>
    </div>
  </div>
</figure>

<style>
  .shot {
    margin: 0; border-radius: 10px; overflow: hidden; background: #141414;
    box-shadow: 0 0 0 1px rgb(255 255 255 / 0.09), 0 30px 60px -30px rgb(0 0 0 / 0.8);
    transition: transform 400ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 400ms cubic-bezier(0.23, 1, 0.32, 1);
  }
  /* Inside a link it lifts and takes a coloured ring on hover and focus. */
  :global(a:hover) > .shot, :global(a:focus-visible) > .shot {
    transform: translateY(-6px);
    box-shadow: 0 0 0 1px rgb(159 208 156 / 0.55), 0 40px 80px -30px rgb(0 0 0 / 0.9);
  }
  :global(a:active) > .shot { transform: translateY(-2px) scale(0.99); }
  @media (prefers-reduced-motion: reduce) { .shot { transition: none; } :global(a:hover) > .shot { transform: none; } }
  .bar { height: 22px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 0 10px; background: #222; }
  .dots { display: flex; gap: 5px; }
  .dots i { width: 7px; height: 7px; border-radius: 50%; background: #3a3a3a; }
  .host { font-size: 10px; color: #8d8e89; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .view { aspect-ratio: 16 / 10; overflow: hidden; background: #f4f2ec; container-type: inline-size; }

  /* The placeholder page: a nav, a headline, two lines, a button and a picture tile. */
  .page { position: relative; height: 100%; padding: 5cqw 7cqw; }
  .page span { display: block; border-radius: 1cqw; }
  .nav { display: flex !important; justify-content: flex-end; gap: 2.5cqw; margin-bottom: 8cqw; }
  .nav i { width: 7cqw; height: 1.4cqw; border-radius: 1cqw; background: #cfccc3; }
  .head { width: 44%; height: 5cqw; background: #26261f; margin-bottom: 3cqw; }
  .line { width: 40%; height: 1.6cqw; background: #b9b6ac; margin-bottom: 1.6cqw; }
  .line.short { width: 28%; }
  .cta { width: 15cqw; height: 4.4cqw; margin-top: 3.5cqw; border-radius: 99px !important; background: var(--tone); }
  .tile { position: absolute; right: 7cqw; top: 14cqw; width: 36%; height: 52%; background: var(--tone); opacity: 0.85; }
</style>
src/lib/preview.tsTypeScript24 lines
// Some demos play themselves in a grid card (anything inside [data-preview]).
// This says when they may: on screen, tab visible, no pointer or focus inside,
// so the demo stops the moment someone reaches for it.

export const playsItself = (root: HTMLElement) =>
  !!root.closest('[data-preview]') && !matchMedia('(prefers-reduced-motion: reduce)').matches;

export function whenFree(root: HTMLElement, onChange: (free: boolean) => void) {
  let visible = false, held = false, free = false;
  const sync = () => {
    const next = visible && !held && !document.hidden;
    if (next === free) return;
    free = next;
    onChange(free);
  };
  const hold = (on: boolean) => { held = on; sync(); };

  new IntersectionObserver(([e]) => { visible = !!e?.isIntersecting; sync(); }).observe(root);
  document.addEventListener('visibilitychange', sync);
  root.addEventListener('pointerenter', () => hold(true));
  root.addEventListener('pointerleave', () => hold(false));
  root.addEventListener('focusin', () => hold(true));
  root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget as Node)) hold(false); });
}