Back to Templates 023

Link in Bio Template Template

FREE

A free creator profile page whose link cards toss onto the page like stickers landing on a desk, drag anywhere with momentum, and tween back into a neat stack on 'Tidy up' - while a tap always just opens the link.

DraggableInertiaPlugin draggablemomentumstaggerload-sequencemicro-interactionpseudo-random Lenis beginner

About This Template

A complete link-in-bio page for a creator: avatar, name, one-line bio, a stack of link cards, one feature slot for whatever is newest, and a tiny footer. The demo dresses it as Tati Moreno, an illustrator in Bristol who draws round animals with bad attitudes; every word, link and colour is yours to change.

The cards are the page. Styled as die-cut stickers with white edges, they toss onto the desk on load - staggered, each landing with its own tilt - and then they are simply loose: drag one anywhere inside the desk and it glides to a stop with real momentum. A 'Tidy up' button sweeps them back into a neat stack. The scatter is deterministic, so the page composes the same way on every visit, and a click or tap on a card always just opens the link - dragging and navigating never fight.

The art direction is a light lilac ground, near-black violet ink, and one hot orange spent only on the loud parts: the handle pill, the tidy button, the drop badge. The avatar is a drawn monogram sticker and the featured drop's artwork is three grumpy pigeons drawn entirely in CSS, so there is not a photograph anywhere - nothing to source, licence or replace before the page looks finished.

With JavaScript off the page is a plain, readable list of links. It deploys as static files with no build step.

What's Included

  • Complete deployable profile page: avatar, name, handle, bio, six link cards, a featured 'latest drop' panel, footer
  • Signature sticker toss: cards drop onto the desk on load, staggered, each over-rotated and settling with a back ease onto its own landing spot
  • Deterministic scatter - every offset and tilt is derived per-index from a seeded hash, so the composition is identical on every load and changes wholesale from one seed value
  • Cards are freely draggable anywhere inside the desk, with InertiaPlugin momentum and edge resistance at the dashed boundary
  • A 6px movement threshold separates tap from drag, so a click or tap on a card always navigates; a drag never does
  • On touch, cards hand vertical swipes back to the page - a finger that lands on a card and swipes up scrolls, a sideways start picks the card up
  • 'Tidy up' button tweens the whole pile back into a neat stack, in order
  • Avatar is a drawn CSS monogram sticker and the drop art is three CSS pigeons - no photography anywhere on the page
  • With JavaScript off the page is a plain stacked list with every link reachable; the desk frame, hint and tidy button only appear once the cards can actually move
  • Reduced motion renders the tidy layout with no toss and no glide - cards can still be dragged, they just stop where you put them
  • Narrow screens keep the tidy stack with a slight settle tilt, sized so no rotated corner can overflow the viewport
  • Six-value token block with RGB triples: repaint lilac, ink and orange and the whole page reskins

Perfect For

  • Link-in-bio pages for illustrators, DJs, makers and writers
  • A personal landing page that is one link in every social profile
  • Small shops that need one page pointing at store, socials and newsletter
  • Musicians and podcasters linking releases, episodes and tour dates
  • Anyone replacing a rented link-list service with a page they own

How It Works

Structure

One script initialises everything inside a single gsap.context, with gsap.matchMedia branching on reduced motion and on whether the screen is wide enough to scatter. Every querySelector is guarded, so the hero, the desk, the drop panel and the footer can each be deleted and the rest keeps working. The has-js class is only added once GSAP is confirmed present, and every animation start state is set from JavaScript, so a blocked CDN leaves a readable page.

The sticker toss

Each card's landing spot - x offset, y jitter, tilt - is derived from its index through a seeded sine hash rather than Math.random, so the scatter is a fixed composition, not a shuffle. The horizontal throw is bounded by the desk's measured width minus the card's, so no card can land outside it at any screen size. The toss itself is a timeline of fromTo tweens: each card starts above its spot, over-rotated and slightly large, and settles with back.out; a short autoAlpha ramp keeps it solid for most of the fall.

Drag without stealing taps

Each card is a Draggable of type x,y with the desk as bounds and InertiaPlugin momentum when its script tag is present. minimumMovement is 6px, so a tap never enters a drag and the link opens normally; when a real drag does happen, the click the release fires is cancelled. Draggable's default touch-action of none is overridden to pan-y, which hands vertical touch swipes back to the browser - a finger that starts on a card and swipes up scrolls the page, while a sideways start picks the card up.

Tidy up

The tidy button tweens every card back to x 0, y 0, rotation 0 with a small per-card delay, restoring the pile's original stacking order. Pressing a card kills any tween still moving it and raises it above the pile, so tidying, tossing and dragging can interrupt each other without fighting.

Reduced motion and no JavaScript

Under reduced motion the cards render in the tidy stacked layout with no toss, no reveal and no inertia, via both the CSS media query and the matchMedia branch - dragging still works, cards just stop where they are released. With JavaScript off the page is a plain stacked list of links, and the desk frame, hint line and tidy button never appear, because there is nothing for them to control.

Plugins Draggable, InertiaPlugin
Difficulty Beginner
Smooth Scroll Lenis Integration
Includes HTML + JS + CSS source, documentation, lifetime updates
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="robots" content="noindex, nofollow">
    <meta name="description" content="Tati Moreno - illustrator in Bristol. Stickers, riso prints, a weekly pigeon comic, and everywhere else to find her, on one page.">
    <title>Tati Moreno - Link in Bio Template | GSAP Vault</title>
    <link rel="stylesheet" href="assets/style.css">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700;800&family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
</head>
<body>

<main class="shell">

    <!-- Profile. The avatar is a drawn monogram sticker, not a photo:
         swap the initial in .avatar-mark and it is yours. -->
    <header class="hero">
        <div class="avatar" data-avatar aria-hidden="true">
            <span class="avatar-mark">t.</span>
        </div>
        <h1 class="hero-name" data-rise>Tati Moreno</h1>
        <p class="hero-handle" data-rise><span class="handle-pill">@tatidoodles</span></p>
        <p class="hero-bio" data-rise>I draw round animals with bad attitudes.
        Stickers, riso prints, and one very cross pigeon comic, from a small
        desk in Bristol.</p>
    </header>

    <!-- The links. Without JavaScript this is exactly what it looks like:
         a plain stacked list, every link reachable. With it, the cards
         toss in like stickers and can be dragged anywhere on the desk.
         The hint line and the Tidy up button are injected by script.js,
         because they describe a feature only the script provides; their
         wording lives in initDeskBar. -->
    <section class="links" id="links" aria-label="Links">
        <div class="desk" data-desk>
            <div class="cards">
                <a class="card" data-card href="#">
                    <span class="card-text">
                        <span class="card-label">The shop</span>
                        <span class="card-sub">Sticker sheets &amp; riso prints</span>
                    </span>
                    <span class="card-arrow" aria-hidden="true">&#8599;</span>
                </a>
                <a class="card" data-card href="#">
                    <span class="card-text">
                        <span class="card-label">Pigeon Post</span>
                        <span class="card-sub">One cross pigeon, weekly. Free to read</span>
                    </span>
                    <span class="card-arrow" aria-hidden="true">&#8599;</span>
                </a>
                <a class="card" data-card href="#">
                    <span class="card-text">
                        <span class="card-label">Commissions</span>
                        <span class="card-sub">Two slots open for autumn</span>
                    </span>
                    <span class="card-arrow" aria-hidden="true">&#8599;</span>
                </a>
                <a class="card" data-card href="#">
                    <span class="card-text">
                        <span class="card-label">Sticker Club</span>
                        <span class="card-sub">A surprise pack in the post, monthly</span>
                    </span>
                    <span class="card-arrow" aria-hidden="true">&#8599;</span>
                </a>
                <a class="card" data-card href="#">
                    <span class="card-text">
                        <span class="card-label">Instagram</span>
                        <span class="card-sub">Works in progress and studio floor</span>
                    </span>
                    <span class="card-arrow" aria-hidden="true">&#8599;</span>
                </a>
                <a class="card" data-card href="#">
                    <span class="card-text">
                        <span class="card-label">Newsletter</span>
                        <span class="card-sub">One email a month, no more</span>
                    </span>
                    <span class="card-arrow" aria-hidden="true">&#8599;</span>
                </a>
            </div>
        </div>
    </section>

    <!-- The latest thing: one feature slot for whatever is newest.
         The pigeons are drawn in CSS - restyle or delete them freely. -->
    <section class="latest" id="latest" aria-labelledby="latest-title">
        <div class="latest-panel" data-rise>
            <div class="latest-text">
                <p class="latest-badge">Latest drop</p>
                <h2 class="latest-title" id="latest-title">Grumpy Pigeons</h2>
                <p class="latest-body">Eight die-cut pigeons, each one furious
                about something different. Waterproof, matte, pigeon-sized.
                Gone when they&rsquo;re gone.</p>
                <a class="latest-cta" href="#">Get the sheet &middot; &pound;6</a>
            </div>
            <div class="latest-art" aria-hidden="true">
                <span class="pigeon pigeon-1"><span class="pigeon-face"><span class="pigeon-brow"></span><span class="pigeon-eye pigeon-eye-l"></span><span class="pigeon-eye pigeon-eye-r"></span><span class="pigeon-beak"></span></span></span>
                <span class="pigeon pigeon-2"><span class="pigeon-face"><span class="pigeon-brow"></span><span class="pigeon-eye pigeon-eye-l"></span><span class="pigeon-eye pigeon-eye-r"></span><span class="pigeon-beak"></span></span></span>
                <span class="pigeon pigeon-3"><span class="pigeon-face"><span class="pigeon-brow"></span><span class="pigeon-eye pigeon-eye-l"></span><span class="pigeon-eye pigeon-eye-r"></span><span class="pigeon-beak"></span></span></span>
            </div>
        </div>
    </section>

</main>

<footer class="foot" data-rise>
    <p class="foot-line">&copy; 2026 Tati Moreno &middot; Bristol, UK &middot;
    <a class="foot-link" href="mailto:hi@tatidoodles.com">hi@tatidoodles.com</a></p>
</footer>

<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/Draggable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/InertiaPlugin.min.js"></script>
<script src="https://unpkg.com/lenis@1.3.17/dist/lenis.min.js"></script>
<script src="assets/script.js"></script>

</body>
</html>
/* ============================================
   TATI MORENO - LINK IN BIO TEMPLATE
   A one-page creator profile whose link cards
   toss onto the page like stickers landing on
   a desk, drag anywhere with momentum, and
   tween back into a neat stack on "Tidy up".

   Sections are independent: delete any block
   from the HTML and the rest keeps working.
   Without JavaScript the page is a plain,
   readable list of links - everything below
   only upgrades it.
   ============================================ */

/* Runs the init straight away if the DOM is already parsed (a script
   executed late or deferred, e.g. by Cloudflare Rocket Loader), and
   waits for DOMContentLoaded otherwise. */
(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})(function initLinkInBio() {

    /* If the GSAP CDN is blocked the page simply stays the plain list:
       has-js is only added once GSAP is confirmed present, and every
       animation start state is set from here, never in the stylesheet. */
    if (typeof gsap === 'undefined') return;
    if (typeof Draggable !== 'undefined') gsap.registerPlugin(Draggable);
    var hasInertia = typeof InertiaPlugin !== 'undefined';
    if (hasInertia) gsap.registerPlugin(InertiaPlugin);

    document.documentElement.classList.add('has-js');

    /* Lenis is optional. Remove its script tag and this block skips. */
    var lenis = null;
    if (typeof Lenis !== 'undefined') {
        lenis = new Lenis({ autoRaf: true });
    }

    /* Per-index pseudo-random in 0..1. Deterministic on purpose: the
       scatter is the page's composition, and a composition should not
       reshuffle on every reload. Change SEED for a different desk. */
    var SEED = 7;
    function prand(i, salt) {
        var s = Math.sin((i + 1) * 12.9898 + (salt + SEED) * 78.233) * 43758.5453;
        return s - Math.floor(s);
    }

    var ctx = gsap.context(function () {
        var mm = gsap.matchMedia();

        mm.add({
            isActive: '(prefers-reduced-motion: no-preference)',
            /* Wide enough that thrown cards have somewhere to land.
               Narrower screens keep the tidy stack with a settle tilt. */
            isScatter: '(min-width: 880px) and (prefers-reduced-motion: no-preference)',
            isReduced: '(prefers-reduced-motion: reduce)'
        }, function (context) {
            var cond = context.conditions;
            var cleanups = [];
            var deck = setupDeck();
            if (deck) initDeskBar(deck);

            if (cond.isReduced) {
                /* Tidy layout, nothing tossed, nothing gliding. The cards
                   may still be dragged - moving a thing you are holding is
                   not an animation - but without inertia. */
                if (deck) {
                    initDrag(deck, cleanups, false);
                    initTidy(deck, cleanups, false);
                }
                return makeCleanup(cleanups);
            }

            if (deck) {
                computePoses(deck, !!cond.isScatter);
                toss(deck);
                initDrag(deck, cleanups, hasInertia);
                initTidy(deck, cleanups, true);
                watchResize(deck, cleanups, !!cond.isScatter);
            }
            initReveal();

            return makeCleanup(cleanups);
        });
    });

    window.gsapContext = ctx;

    window.addEventListener('beforeunload', function () {
        if (ctx) ctx.kill();
        if (lenis) lenis.destroy();
    });

    function makeCleanup(cleanups) {
        return function cleanup() {
            cleanups.forEach(function (fn) { fn(); });
            cleanups.length = 0;
        };
    }

    /* ---------- The desk: find the cards ---------- */
    function setupDeck() {
        var desk = document.querySelector('[data-desk]');
        if (!desk) return null;
        var cards = gsap.utils.toArray('[data-card]', desk);
        if (!cards.length) return null;

        /* Later cards sit on top of earlier ones, like a real pile;
           picking a card up raises it above everything (see initDrag). */
        cards.forEach(function (card, i) {
            gsap.set(card, { zIndex: i + 1 });
        });

        return { desk: desk, cards: cards, poses: [], zTop: cards.length + 1 };
    }

    /* ---------- The hint and the Tidy button ---------- */
    /* Injected rather than written in the HTML: both describe a feature
       only this script provides, so with scripting off they never exist
       and the page makes no promises it cannot keep. Edit the wording
       here. */
    function initDeskBar(deck) {
        if (document.querySelector('.desk-bar')) return;

        var bar = document.createElement('div');
        bar.className = 'desk-bar';
        bar.setAttribute('data-rise', '');

        var hint = document.createElement('p');
        hint.className = 'desk-hint';
        hint.textContent = 'Toss them about if you like. A click still opens the link.';

        var btn = document.createElement('button');
        btn.className = 'tidy-btn';
        btn.type = 'button';
        btn.setAttribute('data-tidy', '');
        btn.textContent = 'Tidy up';

        bar.appendChild(hint);
        bar.appendChild(btn);
        deck.desk.parentNode.insertBefore(bar, deck.desk);
    }

    /* ---------- Where each card lands ---------- */
    function computePoses(deck, scatter) {
        deck.poses = deck.cards.map(function (card, i) {
            if (!scatter) {
                /* Stacked, with a settle tilt small enough that a rotated
                   card's corners stay inside the column: about 2 degrees
                   on a 340px card is a single extra pixel each side. */
                return {
                    x: 0,
                    y: 0,
                    rot: (i % 2 ? -1 : 1) * (0.7 + prand(i, 2) * 1.3)
                };
            }
            /* Horizontal throw is bounded by the desk itself, so no card
               can land outside it whatever the screen size. Vertical
               stays near the card's own slot: the pile keeps its reading
               order even when it looks thrown. */
            var maxX = Math.max(0, (deck.desk.clientWidth - card.offsetWidth) / 2 - 18);
            return {
                x: (prand(i, 0) * 2 - 1) * maxX * 0.92,
                y: (prand(i, 1) * 2 - 1) * 34,
                rot: (prand(i, 2) * 2 - 1) * 7
            };
        });
    }

    /* ---------- The toss ---------- */
    var tossed = false;
    function toss(deck) {
        if (tossed) {
            /* Crossing the 880px breakpoint re-runs this branch; the
               cards just take their new poses without a second throw. */
            deck.cards.forEach(function (card, i) {
                var p = deck.poses[i];
                gsap.set(card, { x: p.x, y: p.y, rotation: p.rot, scale: 1, autoAlpha: 1 });
            });
            return;
        }
        tossed = true;

        var tl = gsap.timeline({ delay: 0.45 });
        deck.cards.forEach(function (card, i) {
            var p = deck.poses[i];
            var at = i * 0.09;
            /* Each card starts above its landing spot, over-rotated and a
               touch large, and settles with a small overshoot - dropped,
               not faded. The opacity ramp is short so the card is solid
               for most of its fall. */
            tl.fromTo(card,
                {
                    x: p.x * 0.4,
                    y: p.y - 170 - prand(i, 4) * 60,
                    rotation: p.rot * 2.6 - 6,
                    scale: 1.1
                },
                {
                    x: p.x, y: p.y, rotation: p.rot, scale: 1,
                    duration: 0.75, ease: 'back.out(1.2)'
                }, at)
              .fromTo(card,
                { autoAlpha: 0 },
                { autoAlpha: 1, duration: 0.26, ease: 'power1.out' }, at);
        });
    }

    /* ---------- Free drag with momentum ---------- */
    /* A card is both a link and a loose object, on a page that scrolls.
       Draggable with type "x,y" claims every touch gesture the moment it
       lands, which would stop a finger that merely starts on a card from
       scrolling the page. So no Draggable listens to a card directly:
       each one is bound to a detached element it never receives events
       from, and a pointerdown on the card decides who owns the gesture.
       A mouse or pen press starts the drag at once; a touch press waits
       until the movement is clearly sideways, and a vertical start is
       left entirely to the browser - the cards carry touch-action: pan-y,
       so the page just scrolls. */
    function initDrag(deck, cleanups, useInertia) {
        if (typeof Draggable === 'undefined') return;

        deck.cards.forEach(function (card) {
            var dragged = false;

            var drag = Draggable.create(card, {
                /* The trigger is a detached element that never receives an
                   event: gestures reach Draggable only through startDrag
                   below, once this code has decided the card owns them. */
                trigger: document.createElement('div'),
                type: 'x,y',
                bounds: deck.desk,
                edgeResistance: 0.8,
                /* Anything under 6px of movement is a tap, and a tap on a
                   card must always follow the link. Draggable only enters
                   a drag past this distance, so taps never see one. */
                minimumMovement: 6,
                dragClickables: true,
                inertia: useInertia,
                onPress: function () {
                    /* The held card rises above the pile and stops obeying
                       any tween still moving it. */
                    deck.zTop += 1;
                    gsap.killTweensOf(card);
                    gsap.set(card, { zIndex: deck.zTop });
                    card.classList.add('is-held');
                },
                onDragStart: function () { dragged = true; },
                onRelease: function () { card.classList.remove('is-held'); }
            })[0];

            var watch = null;

            function onDown(e) {
                if (e.pointerType === 'touch') {
                    /* Commit nothing yet: this may be the start of a page
                       scroll, and a scroll must win. */
                    watch = { id: e.pointerId, x: e.clientX, y: e.clientY };
                } else {
                    drag.startDrag(e);
                }
            }

            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 (dy > dx && dy > 8) {
                    /* Vertical: the browser is scrolling. Stand down. */
                    watch = null;
                } else if (dx > 8 && dx > dy) {
                    /* Sideways: pick the card up, free in both axes. */
                    watch = null;
                    drag.startDrag(e);
                }
            }

            function onEnd() { watch = null; }

            card.addEventListener('pointerdown', onDown);
            card.addEventListener('pointermove', onMove);
            card.addEventListener('pointerup', onEnd);
            card.addEventListener('pointercancel', onEnd);

            /* A gesture that dragged must not also navigate: the click a
               mouse release fires right after a drag is cancelled here. */
            function onClick(e) {
                if (dragged) {
                    e.preventDefault();
                    dragged = false;
                }
            }
            card.addEventListener('click', onClick);

            cleanups.push(function () {
                card.removeEventListener('pointerdown', onDown);
                card.removeEventListener('pointermove', onMove);
                card.removeEventListener('pointerup', onEnd);
                card.removeEventListener('pointercancel', onEnd);
                card.removeEventListener('click', onClick);
                if (drag) drag.kill();
            });
        });
    }

    /* ---------- Tidy up ---------- */
    function initTidy(deck, cleanups, animate) {
        var btn = document.querySelector('[data-tidy]');
        if (!btn) return;

        function tidy() {
            deck.cards.forEach(function (card, i) {
                gsap.killTweensOf(card);
                if (animate) {
                    gsap.to(card, {
                        x: 0, y: 0, rotation: 0, scale: 1,
                        zIndex: i + 1,
                        duration: 0.55, ease: 'power3.inOut', delay: i * 0.045
                    });
                } else {
                    gsap.set(card, { x: 0, y: 0, rotation: 0, scale: 1, zIndex: i + 1 });
                }
            });
        }

        btn.addEventListener('click', tidy);
        cleanups.push(function () { btn.removeEventListener('click', tidy); });
    }

    /* ---------- Keep thrown cards on a shrinking desk ---------- */
    function watchResize(deck, cleanups, scatter) {
        if (!scatter) return;
        var raf = 0;

        function onResize() {
            cancelAnimationFrame(raf);
            raf = requestAnimationFrame(function () {
                deck.cards.forEach(function (card) {
                    var maxX = Math.max(0, (deck.desk.clientWidth - card.offsetWidth) / 2 - 18);
                    var x = parseFloat(gsap.getProperty(card, 'x')) || 0;
                    if (Math.abs(x) > maxX) {
                        gsap.to(card, { x: maxX * Math.sign(x), duration: 0.3, ease: 'power2.out' });
                    }
                });
            });
        }

        window.addEventListener('resize', onResize);
        cleanups.push(function () {
            cancelAnimationFrame(raf);
            window.removeEventListener('resize', onResize);
        });
    }

    /* ---------- Everything else arrives quietly ---------- */
    function initReveal() {
        /* Start states live here, not in the stylesheet, so the page is
           complete with JavaScript disabled. Each group is optional. */
        var avatar = document.querySelector('[data-avatar]');
        var heroBits = gsap.utils.toArray('[data-rise]');
        var tl = gsap.timeline({ defaults: { ease: 'power3.out' } });

        if (avatar) {
            gsap.set(avatar, { scale: 0.4, rotation: -22, autoAlpha: 0 });
            tl.to(avatar, {
                scale: 1, rotation: 0, autoAlpha: 1,
                duration: 0.7, ease: 'back.out(1.7)'
            }, 0.1);
        }
        if (heroBits.length) {
            gsap.set(heroBits, { y: 18, autoAlpha: 0 });
            tl.to(heroBits, {
                y: 0, autoAlpha: 1,
                duration: 0.7, stagger: 0.09
            }, 0.25);
        }
    }
});
!function(e){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e):e()}(function(){if("undefined"!=typeof gsap){"undefined"!=typeof Draggable&&gsap.registerPlugin(Draggable);var e="undefined"!=typeof InertiaPlugin;e&&gsap.registerPlugin(InertiaPlugin),document.documentElement.classList.add("has-js");var t=null;"undefined"!=typeof Lenis&&(t=new Lenis({autoRaf:!0}));var n=gsap.context(function(){gsap.matchMedia().add({isActive:"(prefers-reduced-motion: no-preference)",isScatter:"(min-width: 880px) and (prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(t){var n=t.conditions,d=[],c=function(){var e=document.querySelector("[data-desk]");if(!e)return null;var t=gsap.utils.toArray("[data-card]",e);return t.length?(t.forEach(function(e,t){gsap.set(e,{zIndex:t+1})}),{desk:e,cards:t,poses:[],zTop:t.length+1}):null}();return c&&function(e){if(document.querySelector(".desk-bar"))return;var t=document.createElement("div");t.className="desk-bar",t.setAttribute("data-rise","");var n=document.createElement("p");n.className="desk-hint",n.textContent="Toss them about if you like. A click still opens the link.";var a=document.createElement("button");a.className="tidy-btn",a.type="button",a.setAttribute("data-tidy",""),a.textContent="Tidy up",t.appendChild(n),t.appendChild(a),e.desk.parentNode.insertBefore(t,e.desk)}(c),n.isReduced?(c&&(i(c,d,!1),s(c,d,!1)),r(d)):(c&&(function(e,t){e.poses=e.cards.map(function(n,a){if(!t)return{x:0,y:0,rot:(a%2?-1:1)*(.7+1.3*o(a,2))};var r=Math.max(0,(e.desk.clientWidth-n.offsetWidth)/2-18);return{x:(2*o(a,0)-1)*r*.92,y:34*(2*o(a,1)-1),rot:7*(2*o(a,2)-1)}})}(c,!!n.isScatter),function(e){if(a)return void e.cards.forEach(function(t,n){var a=e.poses[n];gsap.set(t,{x:a.x,y:a.y,rotation:a.rot,scale:1,autoAlpha:1})});a=!0;var t=gsap.timeline({delay:.45});e.cards.forEach(function(n,a){var r=e.poses[a],i=.09*a;t.fromTo(n,{x:.4*r.x,y:r.y-170-60*o(a,4),rotation:2.6*r.rot-6,scale:1.1},{x:r.x,y:r.y,rotation:r.rot,scale:1,duration:.75,ease:"back.out(1.2)"},i).fromTo(n,{autoAlpha:0},{autoAlpha:1,duration:.26,ease:"power1.out"},i)})}(c),i(c,d,e),s(c,d,!0),function(e,t,n){if(!n)return;var a=0;function o(){cancelAnimationFrame(a),a=requestAnimationFrame(function(){e.cards.forEach(function(t){var n=Math.max(0,(e.desk.clientWidth-t.offsetWidth)/2-18),a=parseFloat(gsap.getProperty(t,"x"))||0;Math.abs(a)>n&&gsap.to(t,{x:n*Math.sign(a),duration:.3,ease:"power2.out"})})})}window.addEventListener("resize",o),t.push(function(){cancelAnimationFrame(a),window.removeEventListener("resize",o)})}(c,d,!!n.isScatter)),function(){var e=document.querySelector("[data-avatar]"),t=gsap.utils.toArray("[data-rise]"),n=gsap.timeline({defaults:{ease:"power3.out"}});e&&(gsap.set(e,{scale:.4,rotation:-22,autoAlpha:0}),n.to(e,{scale:1,rotation:0,autoAlpha:1,duration:.7,ease:"back.out(1.7)"},.1));t.length&&(gsap.set(t,{y:18,autoAlpha:0}),n.to(t,{y:0,autoAlpha:1,duration:.7,stagger:.09},.25))}(),r(d))})});window.gsapContext=n,window.addEventListener("beforeunload",function(){n&&n.kill(),t&&t.destroy()});var a=!1}function o(e,t){var n=43758.5453*Math.sin(12.9898*(e+1)+78.233*(t+7));return n-Math.floor(n)}function r(e){return function(){e.forEach(function(e){e()}),e.length=0}}function i(e,t,n){"undefined"!=typeof Draggable&&e.cards.forEach(function(a){var o=!1,r=Draggable.create(a,{trigger:document.createElement("div"),type:"x,y",bounds:e.desk,edgeResistance:.8,minimumMovement:6,dragClickables:!0,inertia:n,onPress:function(){e.zTop+=1,gsap.killTweensOf(a),gsap.set(a,{zIndex:e.zTop}),a.classList.add("is-held")},onDragStart:function(){o=!0},onRelease:function(){a.classList.remove("is-held")}})[0],i=null;function s(e){"touch"===e.pointerType?i={id:e.pointerId,x:e.clientX,y:e.clientY}:r.startDrag(e)}function d(e){if(i&&e.pointerId===i.id){var t=Math.abs(e.clientX-i.x),n=Math.abs(e.clientY-i.y);n>t&&n>8?i=null:t>8&&t>n&&(i=null,r.startDrag(e))}}function c(){i=null}function u(e){o&&(e.preventDefault(),o=!1)}a.addEventListener("pointerdown",s),a.addEventListener("pointermove",d),a.addEventListener("pointerup",c),a.addEventListener("pointercancel",c),a.addEventListener("click",u),t.push(function(){a.removeEventListener("pointerdown",s),a.removeEventListener("pointermove",d),a.removeEventListener("pointerup",c),a.removeEventListener("pointercancel",c),a.removeEventListener("click",u),r&&r.kill()})})}function s(e,t,n){var a=document.querySelector("[data-tidy]");function o(){e.cards.forEach(function(e,t){gsap.killTweensOf(e),n?gsap.to(e,{x:0,y:0,rotation:0,scale:1,zIndex:t+1,duration:.55,ease:"power3.inOut",delay:.045*t}):gsap.set(e,{x:0,y:0,rotation:0,scale:1,zIndex:t+1})})}a&&(a.addEventListener("click",o),t.push(function(){a.removeEventListener("click",o)}))}});
/* ============================================
   TATI MORENO - LINK IN BIO TEMPLATE
   A creator profile page as a desk of stickers:
   light lilac ground, die-cut white-edged link
   cards that toss in on load, drag anywhere,
   and tidy back into a stack.
   ============================================ */

:root {
    /* One light lavender ground, near-black violet ink, and one hot
       orange spent only where it is loud on purpose. The look this page
       refuses: a dark "linktree clone" column of identical grey pills.
       The cards are stickers - white die-cut edges, a thrown scatter -
       and everything else stays quiet around them.

       Contrast measured as used, composited:

       ON THE GROUND (lilac #D8CEEE)
         ink name, card labels via face          10.74
         ink-dim bio, hint, footer                5.92
       ON THE CARD FACE (#F1EDFA)
         ink labels                              14.01
         ink-dim sub lines                        7.72
       ON THE ACCENT (orange #FF4D0D)
         ink handle pill, tidy button, CTA        4.85
       ON THE PANEL (ink #251C38)
         lilac drop title                        10.74
         panel-dim body copy                      7.41
         orange badge text                        4.85

       The accent is banned as TYPE on the ground and on the cards: it
       measures 2.21 on lilac and 3.32 on white, both under the 4.5
       floor. It only ever appears as a fill with ink type on it, or as
       type on the ink panel, where it clears. Keep that rule if you
       repaint: an orange hot enough to sing on lilac is too light to
       read on it.

       Reskin = repaint the six values below, keeping the -rgb triples
       in step; shadows, borders and the desk outline resolve from them. */
    --ground: #D8CEEE;         /* the desk, page base */
    --card: #F1EDFA;           /* sticker faces, inside a white die-cut edge */
    --ink: #251C38;            /* near-black violet: type, and the drop panel */
    --ink-dim: #4E4370;        /* secondary type on ground and cards */
    --panel-dim: #B7A8DC;      /* secondary type on the ink panel only */
    --accent: #FF4D0D;         /* hot orange: fills and panel type, never type on light */

    --ground-rgb: 216, 206, 238;
    --ink-rgb: 37, 28, 56;
    --accent-rgb: 255, 77, 13;

    --font-display: 'Baloo 2', 'Arial Rounded MT Bold', system-ui, sans-serif;
    --font-body: 'Atkinson Hyperlegible', system-ui, sans-serif;

    color-scheme: light;
}

* { box-sizing: border-box; }

html, body {
    margin: 0;
    padding: 0;
    /* Nothing may pan the page sideways, including a card mid-throw. */
    overflow-x: clip;
}

body {
    min-height: 100vh;
    min-height: 100svh;
    display: flex;
    flex-direction: column;
    background: var(--ground);
    color: var(--ink);
    font-family: var(--font-body);
    font-size: 16px;
    line-height: 1.6;
    -webkit-font-smoothing: antialiased;
}

::selection {
    background: var(--accent);
    color: var(--ink);
}

a { color: inherit; }

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

.shell {
    width: min(100% - 2.5rem, 980px);
    margin-inline: auto;
    flex: 1;
}

/* ---------- Profile ---------- */

.hero {
    text-align: center;
    padding: clamp(3rem, 8vh, 5rem) 0 1.5rem;
}

/* The avatar is a drawn monogram on a sticker blob - no photo to
   source, grade or licence. Swap the initial, or replace the whole
   block with an <img> at the same size. */
.avatar {
    display: grid;
    place-items: center;
    width: 104px;
    height: 104px;
    margin: 0 auto 1.1rem;
    background: var(--accent);
    border: 5px solid #fff;
    border-radius: 42% 58% 55% 45% / 48% 42% 58% 52%;
    transform: rotate(-6deg);
    box-shadow: 0 10px 24px rgba(var(--ink-rgb), 0.18);
}

.avatar-mark {
    font-family: var(--font-display);
    font-weight: 800;
    font-size: 3.2rem;
    line-height: 1;
    color: var(--ink);
    transform: rotate(6deg) translateY(-4px);
}

.hero-name {
    margin: 0 0 0.6rem;
    font-family: var(--font-display);
    font-weight: 800;
    /* Display face, capped well under 6rem: it is a name, not a poster. */
    font-size: clamp(2.6rem, 7.5vw, 4.25rem);
    line-height: 1.05;
    letter-spacing: -0.01em;
}

.hero-handle { margin: 0 0 1.1rem; }

.handle-pill {
    display: inline-block;
    padding: 0.3rem 1rem 0.35rem;
    background: var(--accent);
    color: var(--ink);
    font-weight: 700;
    font-size: 1rem;
    border-radius: 999px;
    transform: rotate(-1.5deg);
}

.hero-bio {
    max-width: 34rem;
    margin: 0 auto;
    color: var(--ink-dim);
    font-size: 1.06rem;
}

/* ---------- The desk of links ---------- */

.links { padding: 2.25rem 0 1rem; }

/* The hint and the Tidy button only mean anything once the cards can
   move, so the whole bar is injected by script.js rather than written
   in the HTML - with scripting off it simply never exists. */
.desk-bar {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 1rem;
    width: min(100%, 640px);
    margin: 0 auto 0.9rem;
}

.desk-hint {
    margin: 0;
    color: var(--ink-dim);
    font-size: 0.92rem;
}

.tidy-btn {
    flex: none;
    padding: 0.45rem 1.1rem 0.5rem;
    background: var(--accent);
    color: var(--ink);
    font-family: var(--font-display);
    font-weight: 700;
    font-size: 0.95rem;
    line-height: 1;
    border: none;
    border-radius: 999px;
    cursor: pointer;
    box-shadow: 0 4px 12px rgba(var(--accent-rgb), 0.35);
}

.tidy-btn:hover { filter: brightness(1.06); }
.tidy-btn:active { transform: translateY(1px); }

/* The play surface. Its dashed edge is the cards' drag boundary made
   visible; without JavaScript there is nothing to drag, so no frame. */
.desk { padding: 0; }

.has-js .desk {
    border: 2px dashed rgba(var(--ink-rgb), 0.25);
    border-radius: 28px;
    padding: clamp(1.1rem, 3vw, 2rem);
}

.cards {
    display: flex;
    flex-direction: column;
    gap: 0.9rem;
    width: min(100%, 420px);
    margin: 0 auto;
}

/* A die-cut sticker: pale face, thick white edge, soft drop shadow.
   GSAP owns the card's transform, so nothing here transitions it. */
.card {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.9rem;
    padding: 0.9rem 1.2rem;
    background: var(--card);
    border: 4px solid #fff;
    border-radius: 20px;
    text-decoration: none;
    box-shadow: 0 8px 20px rgba(var(--ink-rgb), 0.14);
    transition: box-shadow 0.25s ease;
    will-change: transform;
}

.has-js .card {
    cursor: grab;
    /* Vertical touch swipes stay the page's to scroll; only a sideways
       start picks a card up (see initDrag in script.js). */
    touch-action: pan-y;
}

.card.is-held {
    cursor: grabbing;
    box-shadow: 0 18px 40px rgba(var(--ink-rgb), 0.26);
}

.card-text {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    min-width: 0;
}

.card-label {
    font-family: var(--font-display);
    font-weight: 700;
    font-size: 1.3rem;
    line-height: 1.15;
    color: var(--ink);
}

.card-sub {
    font-size: 0.9rem;
    color: var(--ink-dim);
}

.card-arrow {
    flex: none;
    font-family: var(--font-display);
    font-weight: 700;
    font-size: 1.25rem;
    color: var(--ink);
}

/* ---------- The latest thing ---------- */

.latest { padding: 2.5rem 0 3rem; }

.latest-panel {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 2rem;
    width: min(100%, 760px);
    margin: 0 auto;
    padding: clamp(1.6rem, 4vw, 2.5rem) clamp(1.4rem, 4vw, 2.75rem);
    background: var(--ink);
    border-radius: 28px;
    color: var(--ground);
}

.latest-badge {
    margin: 0 0 0.4rem;
    color: var(--accent);
    font-weight: 700;
    font-size: 0.85rem;
    letter-spacing: 0.14em;
    text-transform: uppercase;
}

.latest-title {
    margin: 0 0 0.55rem;
    font-family: var(--font-display);
    font-weight: 800;
    font-size: clamp(1.8rem, 4vw, 2.5rem);
    line-height: 1.1;
    color: var(--ground);
}

.latest-body {
    max-width: 26rem;
    margin: 0 0 1.3rem;
    color: var(--panel-dim);
    font-size: 0.98rem;
}

.latest-cta {
    display: inline-block;
    padding: 0.6rem 1.4rem 0.65rem;
    background: var(--accent);
    color: var(--ink);
    font-family: var(--font-display);
    font-weight: 700;
    font-size: 1rem;
    line-height: 1;
    text-decoration: none;
    border-radius: 999px;
}

.latest-cta:hover { filter: brightness(1.06); }

.latest-panel :focus-visible { outline-color: var(--ground); }

/* Three grumpy pigeons, drawn in CSS on white sticker rounds. They are
   decoration: delete .latest-art and the panel simply narrows. */
.latest-art {
    flex: none;
    position: relative;
    width: 190px;
    height: 170px;
}

.pigeon {
    position: absolute;
    display: grid;
    place-items: center;
    width: 96px;
    height: 96px;
    background: #fff;
    border-radius: 50%;
    box-shadow: 0 8px 18px rgba(var(--ink-rgb), 0.4);
}

.pigeon-1 { top: 0; left: 0; transform: rotate(-8deg); }
.pigeon-2 { top: 18px; right: 0; transform: rotate(6deg); }
.pigeon-3 { bottom: 0; left: 34px; transform: rotate(-3deg); z-index: 1; }

.pigeon-face {
    position: relative;
    width: 66px;
    height: 66px;
    background: #8F86A8;
    border-radius: 50%;
}

.pigeon-brow {
    position: absolute;
    top: 20px;
    left: 13px;
    width: 40px;
    height: 8px;
    background: var(--ink);
    border-radius: 4px;
    /* One bar, clipped to a shallow V: the frown. */
    clip-path: polygon(0 0, 50% 62%, 100% 0, 100% 55%, 50% 100%, 0 55%);
}

.pigeon-eye {
    position: absolute;
    top: 28px;
    width: 9px;
    height: 9px;
    background: var(--ink);
    border-radius: 50%;
}

.pigeon-eye-l { left: 17px; }
.pigeon-eye-r { right: 17px; }

.pigeon-beak {
    position: absolute;
    top: 39px;
    left: 50%;
    transform: translateX(-50%);
    width: 0;
    height: 0;
    border-left: 7px solid transparent;
    border-right: 7px solid transparent;
    border-top: 10px solid var(--accent);
}

/* ---------- Footer ---------- */

.foot {
    text-align: center;
    padding: 0 1.25rem 2rem;
}

.foot-line {
    margin: 0;
    color: var(--ink-dim);
    font-size: 0.88rem;
}

.foot-link {
    color: var(--ink-dim);
    text-decoration: underline;
    text-underline-offset: 2px;
}

.foot-link:hover { color: var(--ink); }

/* ---------- Responsive ---------- */

@media (max-width: 720px) {
    .latest-panel {
        flex-direction: column;
        align-items: flex-start;
    }

    .latest-art {
        align-self: center;
        width: 210px;
        height: 120px;
    }

    .pigeon { width: 82px; height: 82px; }
    .pigeon-face { width: 56px; height: 56px; }
    .pigeon-brow { top: 16px; left: 10px; width: 36px; }
    .pigeon-eye { top: 23px; }
    .pigeon-eye-l { left: 13px; }
    .pigeon-eye-r { right: 13px; }
    .pigeon-beak { top: 32px; }
    .pigeon-1 { top: 0; left: 8px; }
    .pigeon-2 { top: 10px; right: 8px; }
    .pigeon-3 { bottom: 0; left: 64px; }
}

@media (max-width: 480px) {
    .desk-bar { flex-direction: column; align-items: stretch; gap: 0.6rem; }
    .tidy-btn { align-self: flex-start; }
}

/* ---------- Reduced motion ---------- */

@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }
}

A complete one-page creator profile: avatar, name, one-line bio, a stack of link cards, one feature slot for whatever is newest, and a tiny footer. The cards are the page: styled as die-cut stickers, they toss onto the desk on load, drag anywhere with momentum, and a Tidy up button sweeps them back into a neat stack. A tap on a card always just opens the link.

The demo dresses it as Tati Moreno, an illustrator in Bristol; every word, link and colour is yours to change. This template is free.

Quick Start

  1. Copy index.html and the assets/ folder to your server. There is no build step; it deploys as static files.
  2. Replace the name, handle, bio and footer with your own words, and the avatar initial in .avatar-mark with yours.
  3. Point each card's href at a real destination and edit its label and sub line.
  4. Rewrite the Latest drop panel for whatever you are pushing this month, or delete the whole #latest section.

Open index.html in a browser. That's the whole install.

The cards

Each card is one block. Add as many as you like, delete freely, reorder freely; the toss, drag and tidy all read the list at load time:

<a class="card" data-card href="https://example.com/shop">
    <span class="card-text">
        <span class="card-label">The shop</span>
        <span class="card-sub">Sticker sheets &amp; riso prints</span>
    </span>
    <span class="card-arrow" aria-hidden="true">&#8599;</span>
</a>

The sub line is optional; remove the card-sub span and the card slims down.

The hint line above the desk and the Tidy up button are not in the HTML: initDeskBar in assets/script.js injects them, because they describe a feature only the script provides. Edit their wording there, or delete that function's call to ship without them.

Tap versus drag

A card is both a link and a draggable object, and the two never fight:

  • Movement under 6px is a tap, and a tap always navigates. Draggable only enters a drag past that distance (minimumMovement in initDrag).
  • When a real drag happens, the click that the mouse release fires is cancelled, so a throw never accidentally opens a link.
  • On touch, a finger that lands on a card and swipes vertically scrolls the page: the cards carry touch-action: pan-y, which hands vertical gestures back to the browser. A sideways start picks the card up.

The scatter is deterministic

Landing spots and tilts are derived from each card's index through a seeded sine hash. There is no Math.random, so the desk composes exactly the same way on every visit. Change SEED at the top of initLinkInBio in assets/script.js for a different composition; every value from 1 upward is a different desk.

The horizontal throw is bounded by the desk's own measured width, so no card can land outside the dashed frame at any screen size. Below 880px the cards keep the tidy stack with a slight settle tilt instead, sized so a rotated corner never overflows a phone screen.

Momentum is optional

With the InertiaPlugin script tag on the page, a thrown card glides to a stop against the desk's edge resistance. Remove that tag and everything still works; cards simply stop where you release them.

The latest drop panel

One feature slot for whatever is newest: a badge, a title, two lines and a button, on an ink panel. The three grumpy pigeons are drawn entirely in CSS; restyle them, or delete .latest-art and the panel simply narrows. Swap the whole panel's copy monthly; it is the only part of the page meant to change often.

Palette and type

Six colours and two faces run the whole page. In :root:

--ground: #D8CEEE;      /* the desk, page base */
--card: #F1EDFA;        /* sticker faces, inside a white die-cut edge */
--ink: #251C38;         /* near-black violet: type, and the drop panel */
--ink-dim: #4E4370;     /* secondary type on ground and cards */
--panel-dim: #B7A8DC;   /* secondary type on the ink panel only */
--accent: #FF4D0D;      /* hot orange: fills and panel type, never type on light */

Repaint those and keep the matching --*-rgb triples in step; the shadows, the desk's dashed frame and the tidy button's glow all resolve from the triples, so a swap that misses them leaves those the old colour. The faces are Baloo 2 for display and Atkinson Hyperlegible for body and UI; swap the two --font-* tokens and the Google Fonts link to change them.

One measured constraint to respect if you recolour, explained in full in the comment at the top of style.css: the orange is never a text colour on the light surfaces. It measures 2.21:1 on the lilac and 3.32:1 on white, both under the 4.5:1 floor, so it only ever appears as a fill with ink type on it, or as type on the ink panel, where it measures 4.85:1.

Deleting pieces

Every block is independent: the script checks for each piece and skips what it doesn't find. Delete the hero, the drop panel, the footer, or any card freely; nothing else breaks.

Accessibility

  • Without JavaScript the page is a plain, readable stacked list with every link reachable. The desk frame, hint line and tidy button never appear, because there is nothing for them to control. The has-js class is only added once GSAP is confirmed present, so a blocked CDN behaves the same way.
  • With reduced motion the cards render in the tidy stacked layout (no toss, no reveal, no glide) via both the CSS media query and the matchMedia branch. Dragging still works; cards just stop where you put them.
  • Cards are real <a> elements: they are tabbable in order, Enter follows them, and focus is visible throughout. The tidy button is a real <button>.
  • The avatar, arrows and pigeons are aria-hidden decoration.

Browser Support

Modern evergreen browsers. The layout is flexbox and the stickers are plain CSS, so with anything missing the page degrades to the stacked list.

Dependencies

  • GSAP core (CDN)
  • GSAP Draggable (CDN): the drag surface; without it the cards are a static list
  • GSAP InertiaPlugin (CDN, optional): the glide; remove freely
  • Lenis (CDN, optional): smooth scrolling; remove freely

Your Cart

Your cart is empty

Browse Effects