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); });
}