023
AI ready Refreshed

Link in Bio Template

Free

A free link-in-bio page, links first: a giant wordmark on one loud colour, a small pile of photo prints for the three latest things (flick one away to see the next, tap to open) and the full list of links on the first screen. Mariam Odede, a potter in Margate, is the worked example; it suits musicians, podcasters, small shops and writers just as well.

DraggableInertiaPluginScrollTrigger beginner
9 more details
draggabletouch-dragmomentumkeyboard-navigationfocus-managementload-sequencestaggerscroll-revealprogressive-enhancement

Updated

About this template

A complete link-in-bio page built for the phone, where almost everyone arrives from a social profile, and built links first. The first screen holds a giant two-word wordmark, a one-line bio, a small pile of photo prints for the three latest things and the start of the full link list. Below sit one dark block for the next thing happening, a short about section, a newsletter sign-up and a footer.

Read the full template overview

The pile is the hands-on part. Each print is a photograph with its label written on the paper border. Flick the top print sideways and it flies off, swings under the pile and the next one surfaces; drag it a little and let go and it springs back; tap it and the link opens. Everything on the pile is also in the plain list right underneath, so nobody has to flick to find a link.

The worked example is Mariam Odede, a potter in Margate who sells her work in kiln openings, teaches Thursday clay classes and makes plates for restaurants. It is dressed in one persimmon glaze colour with near-black ink and a fat, soft display face. Change the two words, the photos and one colour token and it becomes a musician's release page, a podcaster's episode hub, a bakery's drop page or a writer's book launch.

It deploys as static files with no build step, and works without JavaScript as a top print plus the full list.

Link in Bio Template - website template preview

What's included

11 items
  • Complete phone-first link page: wordmark, one-line bio, a pile of the three latest things, the full link list on the first screen, a dark next-event block with countdown, about section, newsletter sign-up and footer
  • The three latest links as a small pile of photo prints: flick or drag the top print away and it tucks under the pile, revealing the next
  • A tap or click always opens the link: nothing reaches the drag until the pointer has moved 6px, and a gesture that dragged never navigates
  • Vertical swipes that start on a print still scroll the page; only a clearly sideways movement picks the print up
  • Arrow keys cycle a focused print, with each new print announced to screen readers
  • Giant two-word wordmark, stacked on phones and on one line on desktop, fitted to the column after the font loads
  • Seeded print tilts and letter wobble: the composition is the same on every load and changes wholesale from one value
  • Every link sits in a plain list that starts on the first screen, for keyboard, screen readers, no-JS visitors and skimmers
  • Countdown block that takes an ISO date, or rolls forward to next Saturday for a demo that never goes stale
  • Real photography bundled as baked WebP derivatives, with sources credited in the manifest
  • Reduced motion swaps the throw for a crossfade; without JavaScript the top print and the full list remain, and covered prints are never focusable

Perfect for

5 use cases
  • Link-in-bio pages for makers, potters, illustrators and small studios
  • Musicians linking a release, tour dates, merch and a mailing list
  • Podcasters pointing at the latest episode, live shows and the back catalogue
  • Small shops and bakeries announcing a drop, workshops and wholesale
  • Writers and chefs with a new book, events, a supper club or a newsletter

How it works

5 sections

Structure

One script runs everything inside a single GSAP context with one teardown, and a matchMedia split between full motion and reduced motion. Each part (wordmark, pile, countdown, form, reveals) checks for its markup first, so any block can be deleted from the HTML. Content is pre-hidden only under a class set synchronously in the head, and the script removes that class if GSAP fails to load.

The print pile

The three latest prints sit in an ordered list stacked in one place, each with a pose derived from its depth: deeper prints fan out further, alternating sides, with seeded tilts. Sending a print back is one timeline: the top print flies out at the speed it was thrown, drops beneath the others at the turn and slides into the deepest pose while the rest move up one place. Only the top print is focusable; the others are inert until they surface.

Tap versus flick

A press commits nothing. If the pointer leaves before moving 6px it is an ordinary click and the link opens natively. Past that, a mouse picks the print up at once, while a finger only does when the movement is clearly sideways, so a vertical swipe scrolls the page. On release, distance or speed decides between sending the print back and springing it home, and the click that follows a real drag is cancelled.

The wordmark

Each word is split into letters with a seeded wobble, then sized to the column once the web font has loaded, and again on resize. On phones the two words stack and their size is also held to the window height, so the first rows of the link list stay on the first screen; on desktop they run across the top on one line above the bio, the pile and the list. The letters ripple in the direction of each throw. The visible words are decoration; a visually hidden heading carries the name.

Reduced motion and no JavaScript

With reduced motion there is no deal-in, no drag and no throw: the arrow keys swap prints with a short crossfade. Without JavaScript, or with the CDN blocked, the top print is a working link with the other two tilted behind it; they ship inert, so keyboard focus moves straight from the top print to the full list underneath.

Plugins Draggable, InertiaPlugin, ScrollTrigger
Difficulty Beginner
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this template's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Questions about this template

Which kinds of creators is this link page right for?

Anyone with one link in a social profile and a handful of places to send people: makers and potters (the worked example), musicians with a release and tour dates, podcasters, small shops and bakeries with drops, writers and chefs. The pile shows your three latest things as photo prints, so it works best when those have a good photograph each.

If visitors can flick the prints, does tapping one still open the link?

Yes. Nothing is handed to the drag until a finger or pointer has moved 6px, so a tap is an ordinary click and opens the link. A gesture that really dragged never navigates. On phones, a swipe that starts on a print and goes up or down scrolls the page; only a sideways movement picks the print up.

Will people who never flick still find every link?

Yes. The full list of links starts on the first screen, directly under the pile, with a small crop of each photo, and it includes the three on the pile. Only the top print can take keyboard focus, so Tab moves from it straight into the list; the arrow keys turn the pile for anyone who wants to browse it.

How do I swap in my own photos and links?

The pile is three list items and the full list is one row per link; the three latest things appear in both. Replace the files in assets/img with your own 4:5 photos at the same names, or new names in both places, and change each link's href. Print labels have a small kicker line and a title; keep titles short enough for one line.

Does the newsletter sign-up actually collect emails?

Not on its own. The Kiln Diary form is a static front end: it checks the email field and shows a thank-you line, but sends nothing. Point the form's action at your list provider, such as Buttondown, Mailchimp, ConvertKit or Formspree, and remove the small submit handler in the script, or post the address from that handler.

Can I use a different colour or a one-word name?

Yes. The ground is one token, so a different loud colour repaints the page; keep the ink on it at 4.5:1 or better (the stylesheet notes the darkest safe value for the persimmon). For a one-word name, put it in the first wordmark element and delete the second; the script fits whatever is there to the column.

Using the earlier design? Download version 1

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Mariam Odede, potter in Margate</title>
    <meta name="description" content="Kiln openings, Thursday clay classes, the shop and restaurant tableware from Mariam Odede's studio in Margate.">
    <meta name="theme-color" content="#E15A2B">
    <script data-cfasync="false">(function(d){d.classList.add('has-js');setTimeout(function(){if(!d.classList.contains('js-ready'))d.classList.remove('has-js')},4000)})(document.documentElement)</script>
    <link rel="preload" href="assets/fonts/bagel-fat-one-latin.woff2" as="font" type="font/woff2" crossorigin>
    <link rel="preload" href="assets/fonts/onest-latin.woff2" as="font" type="font/woff2" crossorigin>
    <link rel="stylesheet" href="assets/style.css">
    <link rel="preload" as="image" href="assets/img/print-kiln-opening.webp" imagesrcset="assets/img/print-kiln-opening-480.webp 480w, assets/img/print-kiln-opening.webp 900w" imagesizes="(min-width: 64em) 32rem, (min-width: 36em) 19rem, 15rem" fetchpriority="high">
</head>
<body>

<header class="strip">
    <a class="strip__handle" href="#about">@mariamodede</a>
    <a class="strip__now" href="#kiln"><span class="strip__dot" aria-hidden="true"></span>Kiln opening<span class="strip__when"> Saturday</span></a>
</header>

<main>
    <!-- ==================================================================
         HERO: the wordmark, a one-line bio, a small pile of the three
         latest things and then every link. The two wordmark words are
         decoration (aria-hidden); the h1 carries the name for search and
         screen readers. Change all three together.
         ================================================================== -->
    <section class="hero" data-hero aria-labelledby="hero-title">
        <h1 id="hero-title" class="sr-only">Mariam Odede, potter in Margate</h1>

        <div class="wordmark" aria-hidden="true">
            <p class="mark" data-wordmark>Mariam</p>
            <p class="mark" data-wordmark>Odede</p>
        </div>

        <div class="hero__intro">
            <p class="hero__bio">Potter in Margate. Mugs, jugs and kiln openings.</p>
            <!-- Socials and contact sit up here, where visitors arriving from a
                 profile look for them; the footer repeats them. -->
            <ul class="socials" aria-label="Elsewhere">
                <li><a href="#">Instagram</a></li>
                <li><a href="#">TikTok</a></li>
                <li><a href="mailto:studio@mariamodede.example">Email</a></li>
            </ul>
        </div>

        <!-- THE PILE: the three latest things, one print and one link each;
             the first is on top. Two to four work. They also appear in the
             full list below (#links), so nobody has to flick to find one. -->
        <div class="pile" data-pile aria-label="Latest, as a pile of prints" aria-describedby="pile-hint" role="region">
            <ol class="pile__stack" data-pile-stack>
                <li class="print" data-print>
                    <a class="print__link" href="#kiln" draggable="false">
                        <span class="print__photo"><img src="assets/img/print-kiln-opening.webp" srcset="assets/img/print-kiln-opening-480.webp 480w, assets/img/print-kiln-opening.webp 900w" sizes="(min-width: 64em) 32rem, (min-width: 36em) 19rem, 15rem" width="900" height="1125" fetchpriority="high" alt="Small dishes in a deep teal glaze with toasted rims, seen from above" draggable="false"></span>
                        <span class="print__label">
                            <span class="print__kicker">Saturday, 10:00</span>
                            <span class="print__title">Kiln opening</span>
                        </span>
                        <span class="print__arrow" aria-hidden="true">&rarr;</span>
                    </a>
                </li>
                <li class="print" data-print inert aria-hidden="true">
                    <a class="print__link" href="#" draggable="false">
                        <span class="print__photo"><img src="assets/img/print-classes.webp" srcset="assets/img/print-classes-480.webp 480w, assets/img/print-classes.webp 900w" sizes="(min-width: 64em) 32rem, (min-width: 36em) 19rem, 15rem" width="900" height="1125" alt="Mariam beside a student at the studio bench, pressing out a slab of clay" draggable="false"></span>
                        <span class="print__label">
                            <span class="print__kicker">Thursdays, 6.30pm</span>
                            <span class="print__title">Clay classes</span>
                        </span>
                        <span class="print__arrow" aria-hidden="true">&rarr;</span>
                    </a>
                </li>
                <li class="print" data-print inert aria-hidden="true">
                    <a class="print__link" href="#" draggable="false">
                        <span class="print__photo"><img src="assets/img/print-shop.webp" srcset="assets/img/print-shop-480.webp 480w, assets/img/print-shop.webp 900w" sizes="(min-width: 64em) 32rem, (min-width: 36em) 19rem, 15rem" width="900" height="1125" alt="Hand-painted cups in butter yellow, pink and pale blue on a wooden counter" draggable="false"></span>
                        <span class="print__label">
                            <span class="print__kicker">Between firings</span>
                            <span class="print__title">The shop</span>
                        </span>
                        <span class="print__arrow" aria-hidden="true">&rarr;</span>
                    </a>
                </li>
            </ol>

            <!-- The hint only appears once the pile can move (.has-js). -->
            <p class="pile__hint" id="pile-hint">
                <span class="pile__hint-touch">Flick for more</span>
                <span class="pile__hint-mouse">Drag for more</span>
                <span class="sr-only">Use the left and right arrow keys for the next print.</span>
            </p>
            <p class="sr-only" data-pile-status aria-live="polite"></p>
        </div>

        <!-- EVERY LINK: all of them, including the three on the pile. This
             is the path for keyboard, screen readers, no-JS and skimmers. -->
        <nav class="links" id="links" aria-labelledby="links-title">
            <h2 class="links__title" id="links-title">Every link</h2>
            <ul class="links__list">
                <li><a class="link" href="#kiln">
                    <img class="link__thumb" src="assets/img/print-kiln-opening-160.webp" srcset="assets/img/print-kiln-opening-160.webp 160w, assets/img/print-kiln-opening-480.webp 480w" sizes="3.25rem" width="900" height="1125" alt="">
                    <span class="link__text"><span class="link__name">Kiln opening</span><span class="link__meta">Saturday 10:00, 31 pieces</span></span>
                    <span class="link__arrow" aria-hidden="true">&rarr;</span>
                </a></li>
                <li><a class="link" href="#">
                    <img class="link__thumb" src="assets/img/print-classes-160.webp" srcset="assets/img/print-classes-160.webp 160w, assets/img/print-classes-480.webp 480w" sizes="3.25rem" width="900" height="1125" alt="">
                    <span class="link__text"><span class="link__name">Thursday clay classes</span><span class="link__meta">6.30pm, six places, &pound;45</span></span>
                    <span class="link__arrow" aria-hidden="true">&rarr;</span>
                </a></li>
                <li><a class="link" href="#">
                    <img class="link__thumb" src="assets/img/print-shop-160.webp" srcset="assets/img/print-shop-160.webp 160w, assets/img/print-shop-480.webp 480w" sizes="3.25rem" width="900" height="1125" alt="">
                    <span class="link__text"><span class="link__name">The shop</span><span class="link__meta">Cups, mugs and seconds</span></span>
                    <span class="link__arrow" aria-hidden="true">&rarr;</span>
                </a></li>
                <li><a class="link" href="#">
                    <img class="link__thumb" src="assets/img/print-tableware-160.webp" srcset="assets/img/print-tableware-160.webp 160w, assets/img/print-tableware-480.webp 480w" sizes="3.25rem" width="900" height="1125" alt="">
                    <span class="link__text"><span class="link__name">Restaurant tableware</span><span class="link__meta">Three a year, booking 2027</span></span>
                    <span class="link__arrow" aria-hidden="true">&rarr;</span>
                </a></li>
                <li><a class="link" href="#diary">
                    <img class="link__thumb" src="assets/img/print-kiln-diary-160.webp" srcset="assets/img/print-kiln-diary-160.webp 160w, assets/img/print-kiln-diary-480.webp 480w" sizes="3.25rem" width="900" height="1125" alt="">
                    <span class="link__text"><span class="link__name">Kiln Diary</span><span class="link__meta">One email per firing</span></span>
                    <span class="link__arrow" aria-hidden="true">&rarr;</span>
                </a></li>
            </ul>
        </nav>
    </section>

    <!-- ==================================================================
         KILN OPENING: the one dark block. data-opens takes an ISO date
         ("2026-11-07T10:00:00+00:00") or "next-saturday" for a rolling demo.
         ================================================================== -->
    <section class="kiln" id="kiln" data-kiln data-opens="next-saturday" data-opens-hour="10" aria-labelledby="kiln-title">
        <div class="kiln__inner">
            <figure class="kiln__print" data-reveal>
                <img src="assets/img/kiln-plates.webp" width="720" height="900" alt="Glazed plates in teal and dusky pink on a scrubbed wooden table" loading="lazy">
            </figure>
            <div class="kiln__text" data-reveal>
                <p class="kiln__kicker">Next kiln opening</p>
                <h2 class="kiln__date" id="kiln-title" data-opens-date>Saturday, 10:00</h2>
                <p class="kiln__countdown" data-countdown>Doors open online at 10:00 sharp</p>
                <p class="kiln__body">Thirty-one pieces from this firing: mugs from &pound;24, two pouring jugs, a stack of side plates and one big copper-red platter. Everything goes live at once and usually sells within the hour.</p>
                <a class="kiln__cta" href="#">Open the shop</a>
            </div>
        </div>
    </section>

    <!-- ABOUT: one big candid print and a few lines. -->
    <section class="about" id="about" aria-labelledby="about-title">
        <figure class="about__print" data-reveal>
            <img src="assets/img/portrait.webp" width="1000" height="1250" alt="Mariam smiling down at an unfired hand-built pot she is holding in the studio" loading="lazy">
        </figure>
        <div class="about__text" data-reveal>
            <h2 class="about__title" id="about-title">Hello, I&rsquo;m Mariam.</h2>
            <p>I make stoneware in a small studio off Northdown Road: thrown mugs with handles you can get four fingers through, wonky jugs, and plates for The Penny Whelk and two other kitchens on the coast.</p>
            <p>The kiln goes on about every six weeks. Whatever comes out goes on sale that Saturday, seconds and all.</p>
            <p class="about__where">Studio 4, Northdown Road, Cliftonville, Margate. Visits by appointment.</p>
        </div>
    </section>

    <!-- KILN DIARY sign-up. Front end only: wire the form to your list. -->
    <section class="diary" id="diary" aria-labelledby="diary-title">
        <div class="diary__inner" data-reveal>
            <div class="diary__head">
                <figure class="diary__print" aria-hidden="true">
                    <img src="assets/img/print-kiln-opening.webp" width="900" height="1125" alt="" loading="lazy">
                </figure>
                <h2 class="diary__title" id="diary-title">Kiln Diary</h2>
            </div>
            <p class="diary__lede">One email per firing, about six a year: what came out, what cracked, and the time the next opening goes live.</p>
            <form class="diary__form" data-diary-form action="#" method="post">
                <label class="diary__label" for="diary-email">Your email</label>
                <div class="diary__row">
                    <input class="diary__input" id="diary-email" name="email" type="email" autocomplete="email" placeholder="you@example.com" required>
                    <button class="diary__btn" type="submit">Join</button>
                </div>
                <p class="diary__done" data-diary-done role="status"></p>
            </form>
        </div>
    </section>
</main>

<footer class="foot">
    <ul class="foot__links">
        <li><a href="#">Instagram</a></li>
        <li><a href="#">TikTok</a></li>
        <li><a href="mailto:studio@mariamodede.example">Email</a></li>
    </ul>
    <p class="foot__small">Made in Margate. &copy; 2026 Mariam Odede Ceramics.</p>
</footer>

<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/Draggable.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/InertiaPlugin.min.js"></script>
<script defer src="assets/script.js"></script>
</body>
</html>
/**
 * Link in Bio: the print pile
 *
 * The three latest links are photo prints in a small pile on the first
 * screen. Flick or drag the top print away and it tucks under the pile,
 * revealing the next; a tap or click opens it. The arrow keys do the same
 * on a focused print, and the full list right below (#links) carries every
 * link for anyone who never flicks.
 *
 * Every part is guarded: delete the pile, the kiln block or the form from
 * index.html and the rest keeps working.
 *
 * @plugins Draggable, InertiaPlugin (optional, velocity tracking), ScrollTrigger
 */

/* Helpers and settings are declared before first use: the demo build
   obfuscates this file and does not keep function hoisting. */

(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})(function initTemplate() {
    var root = document.documentElement;

    /* No GSAP (blocked CDN): take the head script's gate off and leave the
       page as a plain document: the top print, the full list, everything
       visible. */
    if (typeof gsap === 'undefined') {
        root.classList.remove('has-js');
        return;
    }
    root.classList.add('js-ready');

    var HAS_DRAG = typeof Draggable !== 'undefined';
    var HAS_INERTIA = typeof InertiaPlugin !== 'undefined';
    var HAS_ST = typeof ScrollTrigger !== 'undefined';
    if (HAS_DRAG) gsap.registerPlugin(Draggable);
    if (HAS_INERTIA) gsap.registerPlugin(InertiaPlugin);
    if (HAS_ST) gsap.registerPlugin(ScrollTrigger);

    /* ----------------------------------------------------------------------
       SETTINGS
       ---------------------------------------------------------------------- */
    var SEED = 7;                 // change to re-deal every tilt and wobble
    var TAP_PX = 6;               // under this much movement a press is a tap
    var THROW_PX = 90;            // drag further than this and the print goes
    var THROW_SPEED = 600;        // ...or release faster than this (px/s)

    /* Seeded, not random: the pile and the wordmark compose the same way on
       every load, so the page can be judged (and screenshotted) as a design. */
    function rand(i, salt) {
        var x = Math.sin((i + 1) * 12.9898 + salt * 78.233 + SEED) * 43758.5453;
        return x - Math.floor(x);
    }
    function between(i, salt, min, max) { return min + rand(i, salt) * (max - min); }
    function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }

    var desktopMQ = window.matchMedia('(min-width: 64em)');
    var hero = document.querySelector('[data-hero]');
    var pileEl = document.querySelector('[data-pile]');
    var stack = pileEl ? pileEl.querySelector('[data-pile-stack]') : null;
    var prints = stack ? Array.prototype.slice.call(stack.querySelectorAll('[data-print]')) : [];

    /* ----------------------------------------------------------------------
       WORDMARK: split each word into letters on a seeded, hand-thrown
       wobble, then fit the words to the column once the font has loaded.
       ---------------------------------------------------------------------- */
    var words = Array.prototype.slice.call(document.querySelectorAll('[data-wordmark]'));
    var letters = [];
    words.forEach(function (word) {
        var text = word.textContent.trim();
        word.setAttribute('data-text', text);
        word.textContent = '';
        Array.from(text).forEach(function (ch) {
            var span = document.createElement('span');
            span.className = 'mark__letter';
            span.textContent = ch === ' ' ? ' ' : ch;
            word.appendChild(span);
            letters.push(span);
        });
    });
    var letterBase = letters.map(function (l, i) {
        return { yPercent: between(i, 1, -7, 7), rotation: between(i, 2, -5, 5) };
    });

    function fitWordmark() {
        if (!hero || !words.length) return;
        var cs = getComputedStyle(hero);
        var inner = hero.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
        var current = parseFloat(getComputedStyle(words[0]).fontSize) || 100;
        var widths = words.map(function (w) { return w.offsetWidth || 1; });
        var size;
        if (desktopMQ.matches) {
            /* Desktop: one line across the full width, held to about a
               quarter of the window height so the hero fits one screen. */
            var gapEm = parseFloat(getComputedStyle(words[0].parentNode).columnGap) / current || 0.22;
            var line = widths.reduce(function (a, b) { return a + b; }, 0) + gapEm * current * (words.length - 1);
            size = Math.min(inner / line * current, window.innerHeight * 0.24);
        } else {
            /* Phones: each word fills the column, capped by the window
               height so the first rows of the list stay on screen. */
            size = Math.min.apply(null, widths.map(function (w) { return inner / w; })) * current;
            size = Math.min(size, window.innerHeight * 0.105, 176);
        }
        root.style.setProperty('--mark-size', (size * 0.97).toFixed(1) + 'px');
    }

    /* ----------------------------------------------------------------------
       PILE POSES: depth 0 is the top print. Deeper prints fan out a little
       further each, alternating sides, so the stack reads as a real pile.
       ---------------------------------------------------------------------- */
    function pose(depth) {
        if (depth === 0) return { x: 0, y: 0, rotation: between(0, 5, -1.5, 1.5) };
        var side = depth % 2 ? 1 : -1;
        return {
            x: side * (2 + depth * 1.5) + between(depth, 3, -2, 2),
            y: depth * 2.5 + between(depth, 4, -1, 2),
            rotation: side * (2 + depth * 0.9) + between(depth, 5, -1, 1)
        };
    }
    function zFor(depth) { return 10 + prints.length - depth; }

    var previousContext = window.gsapContext;
    var ctx = gsap.context(function (rootContext) {
        var mm = gsap.matchMedia();
        var pageCleanups = [];

        /* Refit on font load and resize; the pile's size follows CSS. */
        var raf = 0;
        function scheduleFit() {
            cancelAnimationFrame(raf);
            raf = requestAnimationFrame(function () {
                fitWordmark();
                if (HAS_ST) ScrollTrigger.refresh();
            });
        }
        fitWordmark();
        if (document.fonts && document.fonts.ready) document.fonts.ready.then(scheduleFit);
        window.addEventListener('resize', scheduleFit);
        pageCleanups.push(function () {
            window.removeEventListener('resize', scheduleFit);
            cancelAnimationFrame(raf);
        });

        /* ------------------------------------------------------------------
           KILN COUNTDOWN. data-opens is an ISO date, or "next-saturday"
           (with data-opens-hour) so a demo never shows a past opening.
           ------------------------------------------------------------------ */
        (function initKiln() {
            var kiln = document.querySelector('[data-kiln]');
            if (!kiln) return;
            var dateEl = kiln.querySelector('[data-opens-date]');
            var countEl = kiln.querySelector('[data-countdown]');
            var raw = kiln.getAttribute('data-opens') || '';
            var hour = parseInt(kiln.getAttribute('data-opens-hour') || '10', 10);
            var target;
            if (raw === 'next-saturday') {
                var now = new Date();
                target = new Date(now);
                target.setHours(hour, 0, 0, 0);
                target.setDate(now.getDate() + ((6 - now.getDay() + 7) % 7));
                if (target <= now) target.setDate(target.getDate() + 7);
            } else {
                target = new Date(raw);
            }
            if (isNaN(target.getTime())) return;

            if (dateEl) {
                dateEl.textContent = target.toLocaleDateString('en-GB', {
                    weekday: 'short', day: 'numeric', month: 'short'
                });
            }
            function plural(n, word) { return n + ' ' + word + (n === 1 ? '' : 's'); }
            function tick() {
                if (!countEl) return;
                var ms = target - new Date();
                if (ms <= 0) { countEl.textContent = 'Open now'; return; }
                var mins = Math.floor(ms / 60000);
                var days = Math.floor(mins / 1440);
                var hours = Math.floor((mins % 1440) / 60);
                var time = target.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
                countEl.textContent = days > 0
                    ? 'Opens ' + time + ', in ' + plural(days, 'day') + ' ' + plural(hours, 'hour')
                    : 'Opens ' + time + ', in ' + plural(hours, 'hour') + ' ' + plural(mins % 60, 'minute');
            }
            tick();
            var timer = setInterval(tick, 30000);
            pageCleanups.push(function () { clearInterval(timer); });
        })();

        /* ------------------------------------------------------------------
           KILN DIARY FORM: front end only. Point the form's action at your
           list provider and delete this handler, or post from here.
           ------------------------------------------------------------------ */
        (function initDiary() {
            var form = document.querySelector('[data-diary-form]');
            if (!form) return;
            var done = form.querySelector('[data-diary-done]');
            function onSubmit(e) {
                e.preventDefault();
                if (!form.checkValidity()) { form.reportValidity(); return; }
                if (done) done.textContent = 'You are on the list. The next Kiln Diary lands the week of the firing.';
                form.reset();
            }
            form.addEventListener('submit', onSubmit);
            pageCleanups.push(function () { form.removeEventListener('submit', onSubmit); });
        })();

        /* ------------------------------------------------------------------
           MOTION BRANCHES
           ------------------------------------------------------------------ */
        mm.add({
            motion: '(prefers-reduced-motion: no-preference)',
            reduced: '(prefers-reduced-motion: reduce)'
        }, function (context) {
            var reduced = context.conditions.reduced;
            var branchCleanups = [];
            /* add(null, fn) wraps without running: tweens the handler makes
               later belong to this branch and revert with it. (add(fn)
               would run the handler immediately.) */
            function on(el, type, fn) {
                var wrapped = context.add(null, fn);
                el.addEventListener(type, wrapped);
                branchCleanups.push(function () { el.removeEventListener(type, wrapped); });
            }

            /* Wordmark at rest on its wobble. */
            if (letters.length) gsap.set(letters, { yPercent: function (i) { return letterBase[i].yPercent; }, rotation: function (i) { return letterBase[i].rotation; } });
            gsap.set(words, { opacity: 1 });

            function ripple(dir) {
                if (reduced || !letters.length) return;
                var list = dir < 0 ? letters.slice().reverse() : letters;
                list.forEach(function (l, k) {
                    var i = letters.indexOf(l);
                    gsap.to(l, {
                        keyframes: [
                            { yPercent: letterBase[i].yPercent - 12, rotation: letterBase[i].rotation + dir * 5, duration: 0.15, ease: 'power2.out' },
                            { yPercent: letterBase[i].yPercent, rotation: letterBase[i].rotation, duration: 0.45, ease: 'back.out(3)' }
                        ],
                        delay: k * 0.022,
                        overwrite: true
                    });
                });
            }

            /* --------------------------------------------------------------
               THE PILE
               -------------------------------------------------------------- */
            (function initPile() {
                if (!pileEl || !prints.length) return;

                var order = prints.slice();
                var busy = null;
                var statusEl = pileEl.querySelector('[data-pile-status]');

                function finishBusy() {
                    if (busy) { var t = busy; busy = null; t.progress(1); }
                }

                /* Only the top print is reachable; the rest are inert until
                   they surface, so Tab never lands on a covered link. */
                function syncAccess(announce) {
                    order.forEach(function (p, d) {
                        if (d === 0) { p.removeAttribute('inert'); p.removeAttribute('aria-hidden'); }
                        else { p.setAttribute('inert', ''); p.setAttribute('aria-hidden', 'true'); }
                    });
                    var top = order[0];
                    if (announce && statusEl) {
                        var title = top.querySelector('.print__title');
                        statusEl.textContent = 'Print ' + (prints.indexOf(top) + 1) + ' of ' + prints.length + ': ' + (title ? title.textContent : '');
                    }
                }

                function settleAll() {
                    order.forEach(function (p, d) {
                        gsap.set(p, Object.assign({ zIndex: zFor(d), opacity: 1, scale: 1 }, pose(d)));
                    });
                }

                function keepFocus(had) {
                    if (!had) return;
                    var link = order[0].querySelector('a');
                    if (link) link.focus({ preventScroll: true });
                }

                /* Top print out to the side, under the pile, back in place. */
                function sendToBack(dir, throwFrom) {
                    finishBusy();
                    var had = stack.contains(document.activeElement);
                    var top = order.shift();
                    order.push(top);
                    syncAccess(true);
                    keepFocus(had);

                    if (reduced) {
                        settleAll();
                        gsap.fromTo(order[0], { opacity: 0 }, { opacity: 1, duration: 0.22, ease: 'none' });
                        return;
                    }

                    var n = order.length;
                    var outX = dir * (pileEl.offsetWidth * 0.92 + 20);
                    var fromX = gsap.getProperty(top, 'x');
                    var speed = throwFrom ? Math.max(Math.abs(throwFrom.vx), 1500) : 1500;
                    var outDur = clamp(Math.abs(outX - fromX) / speed, 0.14, 0.32);
                    var tl = gsap.timeline({ onComplete: function () { if (busy === tl) busy = null; } });
                    busy = tl;

                    tl.set(top, { zIndex: 100 }, 0)
                      .to(top, { x: outX, y: -16, rotation: dir * 16, duration: outDur, ease: 'power1.out' }, 0)
                      .set(top, { zIndex: zFor(n - 1) })
                      .to(top, Object.assign({ duration: 0.5, ease: 'power3.inOut' }, pose(n - 1)));
                    order.forEach(function (p, d) {
                        if (p === top) return;
                        tl.set(p, { zIndex: zFor(d) }, 0);
                        tl.to(p, Object.assign({ duration: 0.45, ease: 'power3.out' }, pose(d)), 0.08 + d * 0.03);
                    });
                    ripple(dir);
                }

                /* Bottom print out from behind and back on top. */
                function bringToFront() {
                    finishBusy();
                    var had = stack.contains(document.activeElement);
                    var back = order.pop();
                    order.unshift(back);
                    syncAccess(true);
                    keepFocus(had);

                    if (reduced) {
                        settleAll();
                        gsap.fromTo(back, { opacity: 0 }, { opacity: 1, duration: 0.22, ease: 'none' });
                        return;
                    }

                    var outX = -(pileEl.offsetWidth * 0.92 + 20);
                    var tl = gsap.timeline({ onComplete: function () { if (busy === tl) busy = null; } });
                    busy = tl;
                    tl.set(back, { zIndex: 1 }, 0)
                      .to(back, { x: outX, y: -16, rotation: -16, duration: 0.3, ease: 'power2.out' }, 0)
                      .set(back, { zIndex: 100 })
                      .to(back, Object.assign({ duration: 0.45, ease: 'power3.out' }, pose(0)))
                      .set(back, { zIndex: zFor(0) });
                    order.forEach(function (p, d) {
                        if (p === back) return;
                        tl.set(p, { zIndex: zFor(d) }, 0);
                        tl.to(p, Object.assign({ duration: 0.45, ease: 'power3.out' }, pose(d)), 0.12 + d * 0.02);
                    });
                    ripple(-1);
                }

                /* Deal-in: the prints land one by one, bottom first. */
                if (reduced) {
                    settleAll();
                } else {
                    var deal = gsap.timeline({ onComplete: function () { if (busy === deal) busy = null; } });
                    busy = deal;
                    for (var d = order.length - 1; d >= 0; d--) {
                        var p = pose(d);
                        gsap.set(order[d], { zIndex: zFor(d) });
                        deal.fromTo(order[d],
                            { x: p.x + between(d, 6, -30, 30), y: p.y - 70, rotation: p.rotation + between(d, 7, -14, 14), scale: 1.08, opacity: 0 },
                            { x: p.x, y: p.y, rotation: p.rotation, scale: 1, opacity: 1, duration: 0.5, ease: 'power3.out' },
                            (order.length - 1 - d) * 0.08);
                    }
                    if (letters.length) {
                        deal.fromTo(letters,
                            { yPercent: function (i) { return letterBase[i].yPercent - 60; }, opacity: 0 },
                            { yPercent: function (i) { return letterBase[i].yPercent; }, opacity: 1, duration: 0.6, stagger: 0.035, ease: 'back.out(2)' },
                            0);
                    }
                }
                syncAccess(false);

                on(pileEl, 'keydown', function (e) {
                    if (e.key === 'ArrowRight') { e.preventDefault(); sendToBack(1); }
                    else if (e.key === 'ArrowLeft') { e.preventDefault(); bringToFront(); }
                });

                /* Dragging. Each print gets a Draggable bound to a detached
                   trigger, so it only ever starts from the pointerdown below,
                   once this code has decided the print owns the gesture:
                   a mouse press starts at once; a touch waits until the
                   movement is clearly sideways, and a vertical start is left
                   to the browser (touch-action: pan-y), so the page scrolls. */
                if (reduced || !HAS_DRAG) return;

                prints.forEach(function (print) {
                    var link = print.querySelector('a');
                    var dragged = false;
                    var lastX = 0, lastT = 0, vx = 0;
                    if (HAS_INERTIA) InertiaPlugin.track(print, 'x');

                    var drag = Draggable.create(print, {
                        trigger: document.createElement('div'),
                        type: 'x,y',
                        /* Under 6px is a tap: the link opens, no drag starts. */
                        minimumMovement: TAP_PX,
                        dragClickables: true,
                        onPress: function () {
                            finishBusy();
                            gsap.killTweensOf(print);
                            gsap.set(print, { zIndex: 100 });
                            print.classList.add('is-held');
                            lastX = this.x; lastT = performance.now(); vx = 0;
                        },
                        onDragStart: function () { dragged = true; },
                        onDrag: function () {
                            var now = performance.now();
                            var dt = Math.max(now - lastT, 1);
                            vx = vx * 0.4 + ((this.x - lastX) / dt * 1000) * 0.6;
                            lastX = this.x; lastT = now;
                            gsap.set(print, { rotation: pose(0).rotation + clamp(this.x / 12, -14, 14) });
                        },
                        onRelease: function () {
                            print.classList.remove('is-held');
                            var rest = pose(0);
                            if (!dragged) {
                                gsap.set(print, { zIndex: zFor(0) });
                                return;
                            }
                            var speed = HAS_INERTIA ? InertiaPlugin.getVelocity(print, 'x') : vx;
                            var dx = this.x - rest.x;
                            var dir = dx >= 0 ? 1 : -1;
                            var flung = Math.abs(speed) > THROW_SPEED && (speed > 0) === (dx > 0);
                            if (Math.abs(dx) > THROW_PX || flung) {
                                sendToBack(dir, { vx: speed });
                            } else {
                                gsap.to(print, Object.assign({
                                    duration: 0.55,
                                    ease: 'back.out(1.7)',
                                    onComplete: function () { gsap.set(print, { zIndex: zFor(0) }); }
                                }, rest));
                            }
                        }
                    })[0];

                    /* Nothing is handed to Draggable on press. A press that
                       never travels TAP_PX stays an ordinary click on the
                       link, so it navigates natively. Past that, a mouse or
                       pen picks the print up in any direction; a finger only
                       when the movement is clearly sideways, and a vertical
                       start is left to the browser (touch-action: pan-y). */
                    var watch = null;
                    function stopWatching() {
                        watch = null;
                        window.removeEventListener('pointermove', onMove);
                        window.removeEventListener('pointerup', stopWatching);
                        window.removeEventListener('pointercancel', stopWatching);
                    }
                    function onDown(e) {
                        if (order[0] !== print) return;
                        if (e.pointerType !== 'touch' && e.button !== 0) return;
                        dragged = false;
                        watch = { id: e.pointerId, x: e.clientX, y: e.clientY, touch: e.pointerType === 'touch' };
                        window.addEventListener('pointermove', onMove);
                        window.addEventListener('pointerup', stopWatching);
                        window.addEventListener('pointercancel', stopWatching);
                    }
                    function onMove(e) {
                        if (!watch || e.pointerId !== watch.id) return;
                        var dx = Math.abs(e.clientX - watch.x);
                        var dy = Math.abs(e.clientY - watch.y);
                        if (watch.touch) {
                            if (dy > dx && dy > 8) { stopWatching(); return; }   // the page scrolls
                            if (dx > 8 && dx > dy) { stopWatching(); drag.startDrag(e); }
                        } else if (dx > TAP_PX || dy > TAP_PX) {
                            stopWatching();
                            drag.startDrag(e);
                        }
                    }
                    /* A gesture that dragged must not also navigate. */
                    function onClick(e) {
                        if (dragged) { e.preventDefault(); dragged = false; }
                    }

                    print.addEventListener('pointerdown', onDown);
                    if (link) link.addEventListener('click', onClick);

                    branchCleanups.push(function () {
                        print.removeEventListener('pointerdown', onDown);
                        stopWatching();
                        if (link) link.removeEventListener('click', onClick);
                        if (HAS_INERTIA) InertiaPlugin.untrack(print);
                        drag.kill();
                    });
                });

                branchCleanups.push(function () {
                    /* Back to the markup's state: only the first print reachable. */
                    prints.forEach(function (p, i) {
                        if (i === 0) { p.removeAttribute('inert'); p.removeAttribute('aria-hidden'); }
                        else { p.setAttribute('inert', ''); p.setAttribute('aria-hidden', 'true'); }
                    });
                });
            })();

            /* --------------------------------------------------------------
               QUIET REVEALS below the fold
               -------------------------------------------------------------- */
            var reveals = document.querySelectorAll('[data-reveal]');
            if (reduced || !HAS_ST) {
                gsap.set(reveals, { opacity: 1 });
            } else {
                reveals.forEach(function (el) {
                    gsap.fromTo(el, { opacity: 0, y: 24 }, {
                        opacity: 1, y: 0, duration: 0.8, ease: 'power3.out',
                        scrollTrigger: { trigger: el, start: 'top 88%', once: true }
                    });
                });
            }

            return function cleanupBranch() {
                branchCleanups.forEach(function (fn) { fn(); });
                branchCleanups.length = 0;
            };
        });

        return function cleanupPage() {
            pageCleanups.forEach(function (fn) { fn(); });
            root.style.removeProperty('--mark-size');
            words.forEach(function (w) {
                if (w.hasAttribute('data-text')) {
                    w.textContent = w.getAttribute('data-text');
                    w.removeAttribute('data-text');
                }
            });
            if (window.gsapContext === rootContext) {
                root.classList.remove('has-js');
                if (previousContext === undefined) delete window.gsapContext;
                else window.gsapContext = previousContext;
            }
        };
    });
    window.gsapContext = ctx;
});
!function(e){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e):e()}(function(){var e=document.documentElement;if("undefined"!=typeof gsap){e.classList.add("js-ready");var t="undefined"!=typeof Draggable,n="undefined"!=typeof InertiaPlugin,r="undefined"!=typeof ScrollTrigger;t&&gsap.registerPlugin(Draggable),n&&gsap.registerPlugin(InertiaPlugin),r&&gsap.registerPlugin(ScrollTrigger);var o=window.matchMedia("(min-width: 64em)"),a=document.querySelector("[data-hero]"),i=document.querySelector("[data-pile]"),u=i?i.querySelector("[data-pile-stack]"):null,s=u?Array.prototype.slice.call(u.querySelectorAll("[data-print]")):[],c=Array.prototype.slice.call(document.querySelectorAll("[data-wordmark]")),d=[];c.forEach(function(e){var t=e.textContent.trim();e.setAttribute("data-text",t),e.textContent="",Array.from(t).forEach(function(t){var n=document.createElement("span");n.className="mark__letter",n.textContent=" "===t?" ":t,e.appendChild(n),d.push(n)})});var l=d.map(function(e,t){return{yPercent:g(t,1,-7,7),rotation:g(t,2,-5,5)}}),f=window.gsapContext,p=gsap.context(function(o){var a=gsap.matchMedia(),p=[],x=0;function w(){cancelAnimationFrame(x),x=requestAnimationFrame(function(){h(),r&&ScrollTrigger.refresh()})}return h(),document.fonts&&document.fonts.ready&&document.fonts.ready.then(w),window.addEventListener("resize",w),p.push(function(){window.removeEventListener("resize",w),cancelAnimationFrame(x)}),function(){var e=document.querySelector("[data-kiln]");if(e){var t,n=e.querySelector("[data-opens-date]"),r=e.querySelector("[data-countdown]"),o=e.getAttribute("data-opens")||"",a=parseInt(e.getAttribute("data-opens-hour")||"10",10);if("next-saturday"===o){var i=new Date;(t=new Date(i)).setHours(a,0,0,0),t.setDate(i.getDate()+(6-i.getDay()+7)%7),t<=i&&t.setDate(t.getDate()+7)}else t=new Date(o);if(!isNaN(t.getTime())){n&&(n.textContent=t.toLocaleDateString("en-GB",{weekday:"short",day:"numeric",month:"short"})),c();var u=setInterval(c,3e4);p.push(function(){clearInterval(u)})}}function s(e,t){return e+" "+t+(1===e?"":"s")}function c(){if(r){var e=t-new Date;if(e<=0)r.textContent="Open now";else{var n=Math.floor(e/6e4),o=Math.floor(n/1440),a=Math.floor(n%1440/60),i=t.toLocaleTimeString("en-GB",{hour:"2-digit",minute:"2-digit"});r.textContent=o>0?"Opens "+i+", in "+s(o,"day")+" "+s(a,"hour"):"Opens "+i+", in "+s(a,"hour")+" "+s(n%60,"minute")}}}}(),function(){var e=document.querySelector("[data-diary-form]");if(e){var t=e.querySelector("[data-diary-done]");e.addEventListener("submit",n),p.push(function(){e.removeEventListener("submit",n)})}function n(n){n.preventDefault(),e.checkValidity()?(t&&(t.textContent="You are on the list. The next Kiln Diary lands the week of the firing."),e.reset()):e.reportValidity()}}(),a.add({motion:"(prefers-reduced-motion: no-preference)",reduced:"(prefers-reduced-motion: reduce)"},function(e){var o=e.conditions.reduced,a=[];function f(e){!o&&d.length&&(e<0?d.slice().reverse():d).forEach(function(t,n){var r=d.indexOf(t);gsap.to(t,{keyframes:[{yPercent:l[r].yPercent-12,rotation:l[r].rotation+5*e,duration:.15,ease:"power2.out"},{yPercent:l[r].yPercent,rotation:l[r].rotation,duration:.45,ease:"back.out(3)"}],delay:.022*n,overwrite:!0})})}d.length&&gsap.set(d,{yPercent:function(e){return l[e].yPercent},rotation:function(e){return l[e].rotation}}),gsap.set(c,{opacity:1}),function(){if(i&&s.length){var r,c,p,h,x=s.slice(),w=null,b=i.querySelector("[data-pile-status]");if(o)P();else{var E=gsap.timeline({onComplete:function(){w===E&&(w=null)}});w=E;for(var L=x.length-1;L>=0;L--){var S=v(L);gsap.set(x[L],{zIndex:y(L)}),E.fromTo(x[L],{x:S.x+g(L,6,-30,30),y:S.y-70,rotation:S.rotation+g(L,7,-14,14),scale:1.08,opacity:0},{x:S.x,y:S.y,rotation:S.rotation,scale:1,opacity:1,duration:.5,ease:"power3.out"},.08*(x.length-1-L))}d.length&&E.fromTo(d,{yPercent:function(e){return l[e].yPercent-60},opacity:0},{yPercent:function(e){return l[e].yPercent},opacity:1,duration:.6,stagger:.035,ease:"back.out(2)"},0)}C(!1),r=i,c="keydown",p=function(e){"ArrowRight"===e.key?(e.preventDefault(),D(1)):"ArrowLeft"===e.key&&(e.preventDefault(),function(){A();var e=u.contains(document.activeElement),t=x.pop();if(x.unshift(t),C(!0),k(e),o)return P(),void gsap.fromTo(t,{opacity:0},{opacity:1,duration:.22,ease:"none"});var n=-(.92*i.offsetWidth+20),r=gsap.timeline({onComplete:function(){w===r&&(w=null)}});w=r,r.set(t,{zIndex:1},0).to(t,{x:n,y:-16,rotation:-16,duration:.3,ease:"power2.out"},0).set(t,{zIndex:100}).to(t,Object.assign({duration:.45,ease:"power3.out"},v(0))).set(t,{zIndex:y(0)}),x.forEach(function(e,n){e!==t&&(r.set(e,{zIndex:y(n)},0),r.to(e,Object.assign({duration:.45,ease:"power3.out"},v(n)),.12+.02*n))}),f(-1)}())},h=e.add(null,p),r.addEventListener(c,h),a.push(function(){r.removeEventListener(c,h)}),!o&&t&&(s.forEach(function(e){var t=e.querySelector("a"),r=!1,o=0,i=0,u=0;n&&InertiaPlugin.track(e,"x");var s=Draggable.create(e,{trigger:document.createElement("div"),type:"x,y",minimumMovement:6,dragClickables:!0,onPress:function(){A(),gsap.killTweensOf(e),gsap.set(e,{zIndex:100}),e.classList.add("is-held"),o=this.x,i=performance.now(),u=0},onDragStart:function(){r=!0},onDrag:function(){var t=performance.now(),n=Math.max(t-i,1);u=.4*u+(this.x-o)/n*1e3*.6,o=this.x,i=t,gsap.set(e,{rotation:v(0).rotation+m(this.x/12,-14,14)})},onRelease:function(){e.classList.remove("is-held");var t=v(0);if(r){var o=n?InertiaPlugin.getVelocity(e,"x"):u,a=this.x-t.x,i=a>=0?1:-1,s=Math.abs(o)>600&&o>0==a>0;Math.abs(a)>90||s?D(i,{vx:o}):gsap.to(e,Object.assign({duration:.55,ease:"back.out(1.7)",onComplete:function(){gsap.set(e,{zIndex:y(0)})}},t))}else gsap.set(e,{zIndex:y(0)})}})[0],c=null;function d(){c=null,window.removeEventListener("pointermove",f),window.removeEventListener("pointerup",d),window.removeEventListener("pointercancel",d)}function l(t){x[0]===e&&("touch"!==t.pointerType&&0!==t.button||(r=!1,c={id:t.pointerId,x:t.clientX,y:t.clientY,touch:"touch"===t.pointerType},window.addEventListener("pointermove",f),window.addEventListener("pointerup",d),window.addEventListener("pointercancel",d)))}function f(e){if(c&&e.pointerId===c.id){var t=Math.abs(e.clientX-c.x),n=Math.abs(e.clientY-c.y);if(c.touch){if(n>t&&n>8)return void d();t>8&&t>n&&(d(),s.startDrag(e))}else(t>6||n>6)&&(d(),s.startDrag(e))}}function p(e){r&&(e.preventDefault(),r=!1)}e.addEventListener("pointerdown",l),t&&t.addEventListener("click",p),a.push(function(){e.removeEventListener("pointerdown",l),d(),t&&t.removeEventListener("click",p),n&&InertiaPlugin.untrack(e),s.kill()})}),a.push(function(){s.forEach(function(e,t){0===t?(e.removeAttribute("inert"),e.removeAttribute("aria-hidden")):(e.setAttribute("inert",""),e.setAttribute("aria-hidden","true"))})}))}function A(){if(w){var e=w;w=null,e.progress(1)}}function C(e){x.forEach(function(e,t){0===t?(e.removeAttribute("inert"),e.removeAttribute("aria-hidden")):(e.setAttribute("inert",""),e.setAttribute("aria-hidden","true"))});var t=x[0];if(e&&b){var n=t.querySelector(".print__title");b.textContent="Print "+(s.indexOf(t)+1)+" of "+s.length+": "+(n?n.textContent:"")}}function P(){x.forEach(function(e,t){gsap.set(e,Object.assign({zIndex:y(t),opacity:1,scale:1},v(t)))})}function k(e){if(e){var t=x[0].querySelector("a");t&&t.focus({preventScroll:!0})}}function D(e,t){A();var n=u.contains(document.activeElement),r=x.shift();if(x.push(r),C(!0),k(n),o)return P(),void gsap.fromTo(x[0],{opacity:0},{opacity:1,duration:.22,ease:"none"});var a=x.length,s=e*(.92*i.offsetWidth+20),c=gsap.getProperty(r,"x"),d=t?Math.max(Math.abs(t.vx),1500):1500,l=m(Math.abs(s-c)/d,.14,.32),p=gsap.timeline({onComplete:function(){w===p&&(w=null)}});w=p,p.set(r,{zIndex:100},0).to(r,{x:s,y:-16,rotation:16*e,duration:l,ease:"power1.out"},0).set(r,{zIndex:y(a-1)}).to(r,Object.assign({duration:.5,ease:"power3.inOut"},v(a-1))),x.forEach(function(e,t){e!==r&&(p.set(e,{zIndex:y(t)},0),p.to(e,Object.assign({duration:.45,ease:"power3.out"},v(t)),.08+.03*t))}),f(e)}}();var p=document.querySelectorAll("[data-reveal]");return o||!r?gsap.set(p,{opacity:1}):p.forEach(function(e){gsap.fromTo(e,{opacity:0,y:24},{opacity:1,y:0,duration:.8,ease:"power3.out",scrollTrigger:{trigger:e,start:"top 88%",once:!0}})}),function(){a.forEach(function(e){e()}),a.length=0}}),function(){p.forEach(function(e){e()}),e.style.removeProperty("--mark-size"),c.forEach(function(e){e.hasAttribute("data-text")&&(e.textContent=e.getAttribute("data-text"),e.removeAttribute("data-text"))}),window.gsapContext===o&&(e.classList.remove("has-js"),void 0===f?delete window.gsapContext:window.gsapContext=f)}});window.gsapContext=p}else e.classList.remove("has-js");function g(e,t,n,r){return n+function(e,t){var n=43758.5453*Math.sin(12.9898*(e+1)+78.233*t+7);return n-Math.floor(n)}(e,t)*(r-n)}function m(e,t,n){return Math.max(t,Math.min(n,e))}function h(){if(a&&c.length){var t,n=getComputedStyle(a),r=a.clientWidth-parseFloat(n.paddingLeft)-parseFloat(n.paddingRight),i=parseFloat(getComputedStyle(c[0]).fontSize)||100,u=c.map(function(e){return e.offsetWidth||1});if(o.matches){var s=parseFloat(getComputedStyle(c[0].parentNode).columnGap)/i||.22,d=u.reduce(function(e,t){return e+t},0)+s*i*(c.length-1);t=Math.min(r/d*i,.24*window.innerHeight)}else t=Math.min.apply(null,u.map(function(e){return r/e}))*i,t=Math.min(t,.105*window.innerHeight,176);e.style.setProperty("--mark-size",(.97*t).toFixed(1)+"px")}}function v(e){if(0===e)return{x:0,y:0,rotation:g(0,5,-1.5,1.5)};var t=e%2?1:-1;return{x:t*(2+1.5*e)+g(e,3,-2,2),y:2.5*e+g(e,4,-1,2),rotation:t*(2+.9*e)+g(e,5,-1,1)}}function y(e){return 10+s.length-e}});
/* ==========================================================================
   LINK IN BIO: a creator's one link, built as a pile of photo prints

   One loud glaze colour, a fat hand-thrown wordmark and real snapshots that
   are the links themselves. Make it yours by repainting --glaze, changing
   the two wordmark words and swapping the six prints for your own photos.

   Reskin: repaint the colour tokens below. Everything else is one of them
   at another value or opacity.
   ========================================================================== */

/* Self-hosted type (assets/fonts/, SIL OFL 1.1, see OFL.txt): the Latin
   files Google Fonts serves, with its weights and unicode-range. Onest is
   one variable file serving both 400 and 600. */
@font-face {
    font-family: 'Bagel Fat One';
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url(fonts/bagel-fat-one-latin.woff2) format('woff2');
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
    font-family: 'Onest';
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url(fonts/onest-latin.woff2) format('woff2');
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
    font-family: 'Onest';
    font-style: normal;
    font-weight: 600;
    font-display: swap;
    src: url(fonts/onest-latin.woff2) format('woff2');
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
    /* Persimmon (kaki) glaze ground with iron-oxide ink, measured as rendered:
         ink #1E120D on glaze #E15A2B      5.0:1  every word on the glaze
         ink on a hovered list row          6.0:1  (paper at 16% over glaze)
         ink on paper #FBF6EE              17.0:1  print labels, form field
         slip #F6E8D6 on clay #2A1A14      13.9:1  kiln block and footer
         celadon #9FD3C1 on clay           10.0:1  countdown line, focus on clay
       Text on the glaze is full-strength ink only: there is no headroom for
       a dimmed ink. The ground is the design: lighten it freely, but darker
       than about #D75529 drops body text under 4.5:1. Never add a second
       loud colour; the photographs bring the rest. Bagel Fat One is for the
       wordmark and headings only (about 7:1 to body on phones, 10:1 on
       desktop); Onest carries every word you read. */
    --glaze: #E15A2B;
    --glaze-rgb: 225, 90, 43;
    --ink: #1E120D;
    --ink-rgb: 30, 18, 13;
    --paper: #FBF6EE;
    --paper-rgb: 251, 246, 238;
    --clay: #2A1A14;
    --clay-rgb: 42, 26, 20;
    --slip: #F6E8D6;
    --slip-rgb: 246, 232, 214;
    --celadon: #9FD3C1;

    --font-display: 'Bagel Fat One', 'Arial Rounded MT Bold', system-ui, sans-serif;
    --font-body: 'Onest', system-ui, -apple-system, 'Segoe UI', sans-serif;

    --step-body: clamp(1rem, 0.95rem + 0.2vw, 1.0625rem);
    --step-h2: clamp(2.4rem, 1.6rem + 3.6vw, 4.5rem);
    --step-fine: 0.75rem;

    --gutter: clamp(1rem, 4vw, 3rem);
    --section-y: clamp(4rem, 9vw, 7.5rem);

    /* The print. Its photo is 4:5 and its paper border carries the label.
       On phones the pile is about 62% of the width, small enough that the
       wordmark, the bio, the pile and the first rows of the list all fit
       the first screen. */
    --print-w: min(62vw, 15rem);
    --print-pad: 0.45rem;
    --label-h: 3.5rem;
    --photo-h: calc((var(--print-w) - 2 * var(--print-pad)) * 1.25);
    --print-h: calc(var(--photo-h) + var(--print-pad) + var(--label-h));

    /* Wordmark size: a fallback until the script fits the words to the
       column after the font loads. */
    --mark-size: min(22vw, 5.5rem);
}

*, *::before, *::after { box-sizing: border-box; }

html {
    scroll-behavior: auto;
    -webkit-text-size-adjust: 100%;
    background: var(--glaze);
}

body {
    margin: 0;
    background: var(--glaze);
    color: var(--ink);
    font-family: var(--font-body);
    font-size: var(--step-body);
    line-height: 1.55;
    overflow-x: clip;
}

img { display: block; max-width: 100%; height: auto; }

a { color: inherit; }

:focus-visible {
    outline: 3px solid var(--ink);
    outline-offset: 3px;
}

.sr-only {
    position: absolute;
    width: 1px; height: 1px;
    margin: -1px; padding: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
}

/* --------------------------------------------------------------------------
   STRIP: handle and the one thing happening now
   -------------------------------------------------------------------------- */

.strip {
    position: absolute;
    inset: 0 0 auto 0;
    z-index: 5;
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 1rem;
    padding: 0.85rem var(--gutter);
    font-size: var(--step-fine);
    font-weight: 600;
    letter-spacing: 0.12em;
    text-transform: uppercase;
}

.strip a { text-decoration: none; }
.strip a:hover { text-decoration: underline; text-underline-offset: 3px; }

.strip__now {
    display: inline-flex;
    align-items: center;
    gap: 0.5em;
    padding: 0.45em 0.85em;
    border: 2px solid var(--ink);
    border-radius: 99px;
}

.strip__now { white-space: nowrap; }
@media (max-width: 24.9em) { .strip__when { display: none; } }

.strip__dot {
    width: 0.55em; height: 0.55em;
    border-radius: 50%;
    background: var(--ink);
}

/* --------------------------------------------------------------------------
   HERO

   Phones: one column in reading order. Wordmark, one-line bio, a small
   pile of the three latest things, then the full list, which starts on
   the first screen.
   -------------------------------------------------------------------------- */

.hero {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 3rem var(--gutter) 0;
    overflow-x: clip;
}

.wordmark {
    text-align: center;
    pointer-events: none;
    user-select: none;
}

.mark {
    margin: 0;
    font-family: var(--font-display);
    font-weight: 400;
    font-size: var(--mark-size);
    line-height: 0.88;
    letter-spacing: -0.02em;
    white-space: nowrap;
    color: var(--ink);
}

/* Letters the script wraps; it owns their transforms. */
.mark__letter { display: inline-block; will-change: transform; }

.hero__intro { text-align: center; }

.hero__bio {
    max-width: 24rem;
    margin: 0.45rem 0 0;
    font-size: 1rem;
    line-height: 1.4;
}

/* Socials and contact: plain words, so there is no icon set to maintain. */
.socials {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 0.2rem 1.1rem;
    margin: 0.35rem 0 0;
    padding: 0;
    list-style: none;
    font-weight: 600;
    font-size: 0.9375rem;
}

.socials a {
    text-decoration: underline;
    text-decoration-thickness: 0.08em;
    text-underline-offset: 0.2em;
}

.socials a:hover { text-decoration-thickness: 0.16em; }

/* --------------------------------------------------------------------------
   THE PILE
   -------------------------------------------------------------------------- */

.pile {
    position: relative;
    z-index: 2;
    width: var(--print-w);
    margin-top: 1.1rem;
    display: flex;
    flex-direction: column;
    align-items: center;
}

.pile__stack {
    position: relative;
    width: var(--print-w);
    height: var(--print-h);
    margin: 0;
    padding: 0;
    list-style: none;
}

.print {
    position: absolute;
    inset: 0;
    /* Hand vertical swipes to the browser so the page still scrolls. */
    touch-action: pan-y;
    /* Each print is its own layer, so a throw moves pixels instead of
       repainting the shadow every frame (large prints dropped frames). */
    will-change: transform;
}

/* Static stack without the script: the first print on top, the next two
   tilted behind it. The script replaces these with its own seeded pose. */
.print:nth-child(1) { z-index: 6; }
.print:nth-child(2) { z-index: 5; transform: rotate(5deg) translate(6px, 4px); }
.print:nth-child(3) { z-index: 4; transform: rotate(-6deg) translate(-8px, 6px); }
.print:nth-child(4) { z-index: 3; }

/* Without the script only the top print is a working link (the list below
   carries the rest): the covered ones ship inert in the markup, and the
   script moves inert to whichever print is underneath. */
html:not(.has-js) .print:nth-child(n+2) { background: var(--paper); box-shadow: 0 1px 2px rgba(var(--ink-rgb), 0.25), 0 10px 26px rgba(var(--ink-rgb), 0.22); }

.print__link {
    display: block;
    position: relative;
    height: 100%;
    padding: var(--print-pad) var(--print-pad) 0;
    background: var(--paper);
    color: var(--ink);
    text-decoration: none;
    border-radius: 2px;
    box-shadow:
        0 1px 2px rgba(var(--ink-rgb), 0.25),
        0 10px 26px rgba(var(--ink-rgb), 0.22);
    -webkit-user-drag: none;
    user-select: none;
    -webkit-touch-callout: none;
}

/* Covered prints only need a tight edge shadow. A soft 26px shadow on
   every large print cost dropped frames during a throw at 1920. */
.print[inert] .print__link {
    box-shadow: 0 1px 3px rgba(var(--ink-rgb), 0.3);
}

.print.is-held .print__link {
    box-shadow:
        0 2px 4px rgba(var(--ink-rgb), 0.25),
        0 22px 44px rgba(var(--ink-rgb), 0.3);
}

.print__photo {
    display: block;
    height: var(--photo-h);
    overflow: hidden;
    background: var(--clay);
}

.print__photo img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    -webkit-user-drag: none;
    pointer-events: none;
}

.print__label {
    display: flex;
    flex-direction: column;
    justify-content: center;
    height: var(--label-h);
    padding: 0 2.4rem 0 0.25rem;
    line-height: 1.2;
}

.print__kicker {
    font-size: 0.625rem;
    font-weight: 600;
    letter-spacing: 0.12em;
    text-transform: uppercase;
}

.print__title {
    font-size: 1.0625rem;
    font-weight: 600;
    letter-spacing: -0.01em;
}

.print__arrow {
    position: absolute;
    right: 0.6rem;
    bottom: calc(var(--label-h) / 2 - 0.875rem);
    display: grid;
    place-items: center;
    width: 1.75rem; height: 1.75rem;
    border-radius: 50%;
    background: var(--ink);
    color: var(--paper);
    font-size: 1rem;
    line-height: 1;
}

.print__link:focus-visible { outline-offset: 4px; }

.pile__hint {
    display: none;
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
    font-weight: 600;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    text-align: center;
}

.has-js .pile__hint { display: block; }

.pile__hint-touch,
.pile__hint-mouse { display: none; }
.has-js .pile__hint-touch { display: inline; }
@media (pointer: fine) {
    .has-js .pile__hint-touch { display: none; }
    .has-js .pile__hint-mouse { display: inline; }
}
@media (prefers-reduced-motion: reduce) {
    .has-js .pile__hint { display: none; }
}

/* --------------------------------------------------------------------------
   EVERY LINK
   -------------------------------------------------------------------------- */

.links {
    width: 100%;
    max-width: 34rem;
    margin-top: 1.4rem;
}

.links__title {
    margin: 0 0 0.45rem;
    font-size: var(--step-fine);
    font-weight: 600;
    letter-spacing: 0.12em;
    text-transform: uppercase;
}

.links__list {
    margin: 0;
    padding: 0;
    list-style: none;
    border-top: 2px solid var(--ink);
}

.links__list li { border-bottom: 1px solid rgba(var(--ink-rgb), 0.4); }

.link {
    display: grid;
    grid-template-columns: 3.25rem minmax(0, 1fr) auto;
    align-items: center;
    gap: 0.9rem;
    min-height: 4.1rem;
    padding: 0.45rem 0.25rem;
    text-decoration: none;
    transition: background-color 0.15s;
}

.link:hover { background: rgba(var(--paper-rgb), 0.16); }

.link__thumb {
    width: 3.25rem;
    height: 3.25rem;
    object-fit: cover;
    border: 3px solid var(--paper);
    border-radius: 2px;
    box-shadow: 0 2px 6px rgba(var(--ink-rgb), 0.25);
    transform: rotate(-3deg);
    transition: transform 0.2s;
}

.links__list li:nth-child(even) .link__thumb { transform: rotate(3deg); }
.link:hover .link__thumb { transform: rotate(0deg) scale(1.06); }

.link__text { display: flex; flex-direction: column; min-width: 0; }

.link__name { font-weight: 600; font-size: 1.0625rem; line-height: 1.3; }
.link__meta { font-size: 0.875rem; line-height: 1.35; }

.link__arrow { font-weight: 600; font-size: 1.15rem; padding-right: 0.3rem; }

/* --------------------------------------------------------------------------
   KILN OPENING: the one dark block
   -------------------------------------------------------------------------- */

.kiln {
    margin-top: var(--section-y);
    background: var(--clay);
    color: var(--slip);
}

.kiln__inner {
    display: grid;
    gap: 2rem;
    max-width: 68rem;
    margin: 0 auto;
    padding: 0 var(--gutter) var(--section-y);
}

.kiln__print {
    width: min(62vw, 17rem);
    margin: -3.5rem 0 0 auto;
    padding: 0.5rem 0.5rem 2.2rem;
    background: var(--paper);
    border-radius: 2px;
    box-shadow: 0 14px 34px rgba(var(--clay-rgb), 0.45);
    transform: rotate(4deg);
}

.kiln__print img { aspect-ratio: 4 / 5; object-fit: cover; width: 100%; }

.kiln__kicker {
    margin: 0;
    font-size: var(--step-fine);
    font-weight: 600;
    letter-spacing: 0.12em;
    text-transform: uppercase;
}

.kiln__date {
    margin: 0.3rem 0 0;
    font-family: var(--font-display);
    font-weight: 400;
    font-size: clamp(3rem, 2rem + 6vw, 6.5rem);
    line-height: 0.95;
    letter-spacing: -0.01em;
}

.kiln__countdown {
    margin: 0.9rem 0 0;
    color: var(--celadon);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
}

.kiln__body { max-width: 34rem; margin: 1rem 0 0; }

.kiln__cta {
    display: inline-block;
    margin-top: 1.6rem;
    padding: 0.95rem 1.6rem;
    border-radius: 99px;
    background: var(--glaze);
    color: var(--ink);
    font-weight: 600;
    text-decoration: none;
    transition: transform 0.15s;
}

.kiln__cta:hover { transform: rotate(-2deg) scale(1.03); }
.kiln :focus-visible { outline-color: var(--celadon); }

/* --------------------------------------------------------------------------
   ABOUT
   -------------------------------------------------------------------------- */

.about {
    display: grid;
    gap: 2.2rem;
    max-width: 68rem;
    margin: 0 auto;
    padding: var(--section-y) var(--gutter) 0;
}

.about__print {
    width: min(86vw, 30rem);
    margin: 0 0 0 calc(var(--gutter) * -0.4);
    padding: 0.6rem 0.6rem 2.6rem;
    background: var(--paper);
    border-radius: 2px;
    box-shadow: 0 1px 2px rgba(var(--ink-rgb), 0.25), 0 16px 36px rgba(var(--ink-rgb), 0.25);
    transform: rotate(-3deg);
}

.about__print img { aspect-ratio: 4 / 5; object-fit: cover; width: 100%; }

.about__title,
.diary__title {
    margin: 0 0 1rem;
    font-family: var(--font-display);
    font-weight: 400;
    font-size: var(--step-h2);
    line-height: 0.95;
    letter-spacing: -0.01em;
}

.about__text p { max-width: 32rem; margin: 0 0 1rem; }
.about__where { font-weight: 600; }

/* --------------------------------------------------------------------------
   KILN DIARY
   -------------------------------------------------------------------------- */

.diary { padding: var(--section-y) var(--gutter); }

.diary__inner {
    max-width: 40rem;
    margin: 0 auto;
    text-align: center;
}

/* The heading crosses a small tilted print. The print sits behind the second word
   only and the heading is in front with a glaze edge, so no letter is lost. */
.diary__head {
    position: relative;
    display: grid;
    place-items: center;
    margin-bottom: 1.4rem;
}

.diary__print {
    width: clamp(8.5rem, 30vw, 13rem);
    margin: 0;
    padding: 0.4rem 0.4rem 1.6rem;
    background: var(--paper);
    border-radius: 2px;
    box-shadow: 0 1px 2px rgba(var(--ink-rgb), 0.25), 0 12px 28px rgba(var(--ink-rgb), 0.22);
    transform: translateX(55%) rotate(6deg);
}

.diary__print img { aspect-ratio: 4 / 5; object-fit: cover; width: 100%; }

.diary__head .diary__title {
    position: absolute;
    left: 50%;
    top: 58%;
    margin: 0;
    z-index: 1;
    transform: translate(-50%, -50%) rotate(-3deg);
    font-size: clamp(3.6rem, 2rem + 8vw, 8.5rem);
    white-space: nowrap;
    -webkit-text-stroke: 0.07em var(--glaze);
    paint-order: stroke fill;
}

.diary__lede { margin: 0 auto 1.6rem; max-width: 30rem; }

.diary__label {
    display: block;
    margin-bottom: 0.45rem;
    font-size: var(--step-fine);
    font-weight: 600;
    letter-spacing: 0.12em;
    text-transform: uppercase;
}

.diary__row {
    display: flex;
    max-width: 28rem;
    margin: 0 auto;
    border: 2px solid var(--ink);
    border-radius: 99px;
    background: var(--paper);
    overflow: hidden;
}

.diary__input {
    flex: 1;
    min-width: 0;
    padding: 0.9rem 1.1rem;
    border: 0;
    background: transparent;
    color: var(--ink);
    font: inherit;
}

.diary__input::placeholder { color: rgba(var(--ink-rgb), 0.7); }
.diary__input:focus-visible { outline: none; }
.diary__row:focus-within { outline: 3px solid var(--ink); outline-offset: 3px; }

.diary__btn {
    padding: 0 1.4rem;
    border: 0;
    background: var(--ink);
    color: var(--paper);
    font: 600 1rem var(--font-body);
    cursor: pointer;
}

.diary__btn:focus-visible { outline: 3px solid var(--paper); outline-offset: -5px; }

.diary__done { min-height: 1.5em; margin: 0.8rem 0 0; font-weight: 600; }

/* --------------------------------------------------------------------------
   FOOTER
   -------------------------------------------------------------------------- */

.foot {
    padding: 2.6rem var(--gutter) 2.2rem;
    background: var(--clay);
    color: var(--slip);
    text-align: center;
}

.foot__links {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 0.4rem 1.6rem;
    margin: 0;
    padding: 0;
    list-style: none;
    font-family: var(--font-display);
    font-size: clamp(1.6rem, 1.2rem + 1.6vw, 2.4rem);
}

.foot__links a { text-decoration: none; }
.foot__links a:hover { color: var(--glaze); }
.foot :focus-visible { outline-color: var(--celadon); }

.foot__small { margin: 1.2rem 0 0; font-size: 0.8125rem; }

/* --------------------------------------------------------------------------
   DESKTOP (64em and up): one line of wordmark across the width, the pile in the middle
   column where the two words meet, bio left and the list right.
   -------------------------------------------------------------------------- */

@media (min-width: 36em) and (max-width: 63.99em) {
    /* Tablets keep the phone composition with a larger print. */
    :root { --print-w: min(46vw, 19rem); --label-h: 4rem; }
}

@media (min-width: 64em) {
    :root {
        /* Held to the window height so the whole hero fits the first
           screen under a wordmark set at about 13% of the width. */
        --print-w: clamp(16rem, min(28vw, 64vh - 10rem), 32rem);
        --print-pad: 0.55rem;
        --label-h: 4.2rem;
        --mark-size: 12vw;
    }

    /* The wordmark runs across the top on one line; below it, the bio, the
       pile and the full list share one row. Nothing overlaps. */
    .hero {
        display: grid;
        grid-template-columns: minmax(0, 1fr) var(--print-w) minmax(0, 1fr);
        grid-template-areas:
            "mark mark  mark"
            "bio  pile  links";
        column-gap: clamp(3rem, 5.5vw, 7rem);
        row-gap: clamp(1.25rem, 3vh, 2.25rem);
        align-content: center;
        min-height: 100vh;
        min-height: 100svh;
        padding: 4rem var(--gutter) 1.5rem;
    }

    .wordmark {
        grid-area: mark;
        display: flex;
        justify-content: center;
        gap: 0.22em;
        font-size: var(--mark-size);
    }

    .mark { font-size: 1em; }

    .pile { grid-area: pile; margin-top: 0; justify-self: center; }

    .print__kicker { font-size: 0.6875rem; }
    .print__title { font-size: 1.1875rem; }

    .hero__intro {
        grid-area: bio;
        align-self: center;
        justify-self: end;
        max-width: 18rem;
        text-align: left;
    }

    .hero__bio {
        margin: 0;
        font-size: 1.5rem;
        line-height: 1.35;
    }

    .socials {
        justify-content: flex-start;
        margin-top: 1rem;
        font-size: 1.0625rem;
    }

    .links {
        grid-area: links;
        align-self: center;
        justify-self: start;
        max-width: 27rem;
        margin-top: 0;
    }

    .kiln__inner {
        grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
        align-items: center;
        column-gap: 5rem;
    }

    .kiln__print { width: 100%; max-width: 22rem; margin: -4.5rem auto 0; }

    .about {
        grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
        align-items: center;
        column-gap: 5rem;
    }

    .about__print { margin: 0; justify-self: end; }
}

/* --------------------------------------------------------------------------
   REVEAL GATES: pre-hidden before first paint (head script), restored for
   reduced motion and whenever the script removes .has-js.
   -------------------------------------------------------------------------- */

.has-js [data-reveal],
.has-js [data-wordmark] { opacity: 0; }
/* The prints start at 0.01, not 0: invisible, but painted from the first
   frame, so the top print counts as the page's largest paint before GSAP
   loads. The deal-in still fades each one from 0. */
.has-js [data-print] { opacity: 0.01; }

@media (prefers-reduced-motion: reduce) {
    .has-js [data-reveal],
    .has-js [data-print],
    .has-js [data-wordmark] { opacity: 1; }
}

A one-page link-in-bio site, links first. A giant wordmark and a one-line bio sit on one loud ground colour. Below them is a small pile of photo prints for the three latest things: flick the top print away and it tucks under the pile, revealing the next, or tap it to open. The full list of links starts on the first screen, right under the pile. The worked example is Mariam Odede, a potter in Margate: kiln openings, Thursday clay classes, the shop, restaurant tableware, and a newsletter, with Instagram, TikTok and email in a row under the bio.

It suits anyone whose one link lives in a social profile: musicians (release, tour dates, merch, bookings), podcasters (latest episode, live shows, back catalogue), small shops and bakeries (the drop, workshops, wholesale), writers (new book, events, newsletter) and chefs (supper club, classes, cookbook).

Quick Start

  1. Open index.html in a browser, or upload the folder to any static host. There is no build step.
  2. Change the name in three places: the hidden <h1> and the two [data-wordmark] words.
  3. Edit the three prints in [data-pile-stack] (your latest things) and the rows in #links (every link, the three latest included).
  4. Replace the photos in assets/img/ with your own at the same names (4:5, about 900x1125). Each print also has a smaller -480 copy (480x600, for phone-sized prints) and a -160 copy (160x200, for the list thumbnails); replace those too.
  5. Repaint --glaze in assets/style.css.

Using It With Your Own Design

The page is one hero (wordmark, pile, bio, list) followed by optional blocks: the dark kiln block (#kiln), the about section (#about), the newsletter sign-up (#diary) and the footer. Delete any of them and the rest keeps working; the script checks for each part before touching it.

  • The pile is the hero. Keep it on the first screen; on phones the composition is built around a 390px-wide screen with the whole pile, its controls and the bio visible.
  • The wordmark is two words, stacked on phones and on one line on desktop. A one-word name works: put it in the first [data-wordmark] and delete the second. Longer names shrink to fit, because the script sizes the words to the column after the font loads. On phones the size is also held to the window height, so the first rows of the list stay on the first screen.
  • Your own type. Swap --font-display for another heavy, characterful face.
  • Your own links. Two to four prints work on the pile; the list takes as many rows as you need. Each print and each list row is an ordinary <a>; point them anywhere.

The Pile

Markup: [data-pile] holds [data-pile-stack] (an <ol> of [data-print] items, the first on top), the hint line and a visually hidden live region ([data-pile-status]). Prints after the first ship with inert and aria-hidden, so without the script only the top one is reachable; the script moves those attributes as the pile turns.

Each print is a link with three parts: .print__photo (the image), .print__label (.print__kicker and .print__title) and .print__arrow.

  • Flick or drag the top print sideways. Past 90px, or released quickly, it goes to the back of the pile; otherwise it springs home.
  • Tap or click opens the link. Nothing is handed to the drag until the pointer has moved 6px, so a tap is always a plain click. A gesture that did drag never navigates.
  • Vertical swipes scroll the page. On touch, a print is only picked up when the movement is clearly sideways (touch-action: pan-y).
  • Arrow keys on a focused print do the same as a flick: right sends the top print back, left brings the bottom one forward.

Settings at the top of assets/script.js:

Setting Default What it does
SEED 7 Re-deals every print tilt and wordmark wobble. Same value, same composition on every load
TAP_PX 6 Movement under this is a tap
THROW_PX 90 Drag further than this and the print goes to the back
THROW_SPEED 600 Or release faster than this, in px/s

On desktop the wordmark runs across the top on one line, with the bio, the pile and the full list in one row beneath it. The print size follows the window height, so the whole hero fits the first screen.

The Kiln Block

[data-kiln] shows the next date and a countdown. Set data-opens to an ISO date (2026-11-07T10:00:00+00:00), or leave next-saturday with data-opens-hour="10" for a date that always rolls forward. The script writes the date into [data-opens-date] and the countdown into [data-countdown]; without the script both show the static text in the HTML. Rename it for your own drop: a release date, a supper club, a launch.

The Sign-up Form

[data-diary-form] is a front end only. It checks the email field and shows a thank-you line, and sends nothing. Point the form's action at your list provider (Buttondown, Mailchimp, ConvertKit, Formspree) and delete the submit handler in initDiary, or post from that handler.

Palette

Six colour tokens in :root:

Token Value Role
--glaze #E15A2B The ground. Persimmon glaze
--ink #1E120D Every word on the glaze, the wordmark, rules and controls
--paper #FBF6EE Print borders, the form field
--clay #2A1A14 The kiln block and footer
--slip #F6E8D6 Text on clay
--celadon #9FD3C1 The countdown line and focus rings on clay

Ink on the glaze is 5.0:1. Text on the glaze is full-strength ink only, so there is no dimmed ink token. You can lighten the glaze freely, but a ground darker than about #D75529 takes body text under 4.5:1. A different loud ground (acid green, cobalt, butter yellow) works the same way: re-measure ink on it.

Type: Bagel Fat One (display: the wordmark and headings) and Onest 400/600 (everything you read), self-hosted in assets/fonts/ (see Fonts below).

Example

To make it a musician's page:

  • Change the wordmark to the artist name.
  • Make the three prints the new single, the next tour date and the merch drop, and list everything (tour dates, merch, a live video, the mailing list, Instagram) underneath.
  • Turn the kiln block into the release countdown, with data-opens set to the release date.
  • Repaint --glaze to the record sleeve colour.

Accessibility and Fallbacks

  • Only the top print is focusable. The covered prints are inert until they surface, so Tab never lands on a hidden link. Focus moves with the pile when you use the arrow keys.
  • A live region announces each print, for example "Print 2 of 3: Clay classes".
  • The plain list (#links) carries every link for keyboard and screen-reader users, and for anyone who skims. It follows the pile directly, in reading and tab order.
  • The wordmark is decoration (aria-hidden); the hidden <h1> carries the name.
  • Reduced motion removes the deal-in, the drag, the throw and the hint. The arrow keys swap prints with a short crossfade, and the wordmark rests on its wobble.
  • Without JavaScript, or if the GSAP CDN is blocked, the top print shows as a static link with the other two tilted behind it, not focusable. The full list follows directly, and every section is visible.

Dependencies

  • GSAP 3.12+ core, Draggable and ScrollTrigger (the demo pins 3.15.0 from jsDelivr).
  • InertiaPlugin is optional. When present, it measures throw speed; without it the script measures speed itself.
  • No Lenis: one short page does not need smooth scrolling.

Cleanup

Everything runs inside one gsap.context stored on window.gsapContext. window.gsapContext.revert() does the following:

  • Kills every tween, Draggable and ScrollTrigger.
  • Removes the listeners and the countdown timer.
  • Restores the wordmark text and the prints' accessibility attributes.
  • Takes the has-js gate off.

Fonts

Bagel Fat One and Onest are self-hosted in assets/fonts/, so the page makes no render-blocking third-party request for type. The files are the Latin subsets Google Fonts serves: Bagel Fat One is its regular, and Onest is one variable file covering weights 400 and 600. They are declared by the @font-face rules at the top of assets/style.css with the same weights and unicode-range Google uses, and the page preloads both, since the wordmark and the bio share the first screen.

Licence: SIL Open Font License 1.1, full text in assets/fonts/OFL.txt. Bagel Fat One: Copyright 2022 The Bagel Fat Project Authors (https://github.com/JAMO-TYPEFACE/BagelFat). Onest: Copyright 2021 The Onest Project Authors (https://github.com/simpals/onest).

To swap a face, put your .woff2 in assets/fonts/, point its @font-face rule and the token at it, and change the preload in the head. To use Google Fonts instead, delete the @font-face rules and the preloads and add the Google Fonts <link> to the head.

Images and Credits

The images in assets/img/ are cropped, resized and lightly graded WebP derivatives baked from Pexels photographs (Pexels licence), listed with their sources in assets/img-manifest.json:

  • Teal glazed dishes: pexels.com/photo/28271468
  • Studio class at the bench: pexels.com/photo/6694305
  • Hand-painted cups: pexels.com/photo/30225370
  • Tableware from above: pexels.com/photo/3847440
  • Bowls on a kiln shelf: pexels.com/photo/7222326
  • Portrait with a pot: pexels.com/photo/6694761
  • Glazed plates on wood: pexels.com/photo/9736670

To use your own photos, replace a file at the same name and shape, with its -480 and -160 copies for the prints. The page picks the size that suits the screen through srcset, and preloads the top print. You can also change an entry's source in the manifest and re-bake it with bun scripts/build-template-assets.ts link-in-bio-template from the GSAP Vault repository; any image tool that exports 4:5 WebP does the same job.