Northwind
A booking tool for small studios, from first enquiry to paid invoice.
Full-height project sections where a block of the product's own colour wipes in behind each one as it arrives, alternating sides. Scroll inside the box to see both.
A booking tool for small studios, from first enquiry to paid invoice.
Reading notes that find the passage you half remember.
---
// Full-height project sections. As each one comes into view, a block of the product's
// own colour wipes in behind it from the side; the next section flips sides.
// Scroll inside the box to bring each one in, once each.
// In a grid card (inside [data-preview]) the first section wipes in and out on a loop.
// No JavaScript or reduced motion: the slabs are simply there.
// Placeholder products: swap in real names, colours and screenshots.
import Frame from '../browser-frame/Frame.astro';
interface Item { name: string; line: string; meta: string; slab: string; tone: string; host: string }
interface Props { items?: Item[] }
const {
items = [
{ name: 'Northwind', line: 'A booking tool for small studios, from first enquiry to paid invoice.', meta: '2026 · Astro · TypeScript', slab: '#2f5446', tone: '#3b6e5b', host: 'northwind.example' },
{ name: 'Lumen', line: 'Reading notes that find the passage you half remember.', meta: '2025 · React · SQLite', slab: '#8a4a30', tone: '#c96f4a', host: 'lumen.example' },
],
} = Astro.props;
---
<div class="slabdemo" data-slab-demo>
<div class="scroller" tabindex="0" aria-label="Colour slab sections demo. Scroll to bring each section in.">
{items.map((p, i) => (
<article class:list={['project', { flip: i % 2 === 1 }]} style={`--slab: ${p.slab}`} data-project>
<div class="slab" aria-hidden="true"></div>
<div class="grid">
<div class="text">
<h3>{p.name}</h3>
<p class="line">{p.line}</p>
<p class="meta">{p.meta}</p>
</div>
<div class="media"><Frame tone={p.tone} host={p.host} /></div>
</div>
</article>
))}
</div>
</div>
<style>
.slabdemo {
--sl-ink: #f1f1ef; --sl-soft: #b4b5af; --sl-ease: cubic-bezier(0.23, 1, 0.32, 1);
position: absolute; inset: 0; color: var(--sl-ink); container-type: size;
}
.scroller { height: 100%; overflow-y: auto; scrollbar-width: thin; }
.project { position: relative; min-height: 100cqh; display: flex; align-items: center; overflow: hidden; }
.slab {
position: absolute; top: 14%; bottom: 14%; right: 0; width: 54%;
background: var(--slab);
transform: scaleX(0); transform-origin: right;
transition: transform 1200ms var(--sl-ease);
}
.flip .slab { right: auto; left: 0; transform-origin: left; }
.slabdemo:not(.js) .slab, .project.in .slab { transform: none; }
.grid { position: relative; width: 100%; display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 6cqw; align-items: center; padding: 0 7cqw; }
.flip .grid { grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); }
.flip .text { order: 2; }
h3 { margin: 0; font-size: clamp(1.5rem, 6cqw, 3.25rem); font-weight: 500; letter-spacing: -0.03em; line-height: 1.05; }
.line { margin: 0.75rem 0 0; color: var(--sl-ink); font-size: clamp(0.875rem, 1.9cqw, 1.0625rem); max-width: 34ch; }
.meta { margin: 0.75rem 0 0; color: var(--sl-soft); font-size: clamp(0.75rem, 1.5cqw, 0.875rem); }
/* Narrow boxes: picture above, words below, the slab a band behind the picture. */
/* Grid cards keep the side-by-side layout, so the name shows next to the picture. */
@container (max-width: 560px) {
.slabdemo:not(.auto) .grid { grid-template-columns: minmax(0, 1fr); gap: 1.5rem; }
.slabdemo:not(.auto) .text { order: 2; }
.slabdemo:not(.auto) .slab { top: 10%; bottom: auto; height: 40%; width: 100%; }
}
/* A card is too small for the meta line. */
@container (max-height: 420px) { .meta { display: none; } }
.auto .scroller { overflow: hidden; }
@media (prefers-reduced-motion: reduce) { .slab { transform: none; transition: none; } }
</style>
<script>
import { playsItself, whenFree } from '../../lib/preview';
document.querySelectorAll<HTMLElement>('[data-slab-demo]').forEach((root) => {
root.classList.add('js');
const scroller = root.querySelector<HTMLElement>('.scroller');
const projects = [...root.querySelectorAll<HTMLElement>('[data-project]')];
if (!scroller || !projects.length) return;
if (!playsItself(root)) {
// Each section gets its slab once, when a third of it is in the box.
const io = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); }
}, { root: scroller, threshold: 0.35 });
projects.forEach((p) => io.observe(p));
return;
}
// In a card: the first section's slab wipes in, holds, wipes out, rests, repeat.
root.classList.add('auto');
const first = projects[0]!;
let timer = 0;
const step = (on: boolean) => {
first.classList.toggle('in', on);
timer = window.setTimeout(() => step(!on), on ? 3200 : 1600);
};
whenFree(root, (free) => {
clearTimeout(timer);
if (free) timer = window.setTimeout(() => step(!first.classList.contains('in')), 600);
});
});
</script>---
// 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>// 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); });
}