021
AI ready Refreshed

Charity Campaign Template

Free

A free one-page appeal template where visitors give by packing a crate: drag tins, pasta and nappies off a shelf, watch the total rewrite what the gift does, and carry it into the donation form. Worked as a Sheffield food bank's winter appeal.

ScrollTrigger Lenis intermediate
12 more details
drag-interactiontouch-dragkeyboard-navigationstate-transitioncount-upprogress-barscroll-revealclip-pathscrubinfinite-loopform-validationprogressive-enhancement

Updated

About this template

A complete one-page appeal for a charity or community campaign, worked through as Hollins Lane Larder, a food bank in Walkley, Sheffield, raising £38,000 to fill its shelves until spring. The first screen does what the best charity sites do: a full-bleed photograph, a headline as big as the window allows, a give card with amounts that say what they buy, and a raised-so-far strip along the bottom edge.

Read the full template overview

The signature is Pack a crate. Nine items sit on a wooden shelf with their prices; drag one into the crate, tap it or press Enter, and it drops in behind the crate's front wall. The total rolls, a sentence rewrites to say what the crate does (meals, nappies, a Thursday supper), and the same amount appears in the nav, the hero card and the donation form. Fill it for me packs a whole family crate, and Buy it all does the same from a full-bleed supply photograph.

The look is loud on purpose: a compressed display face, saturated green and lilac grounds that change section by section, big real photography of the people who run the place, and one yolk yellow kept for everything you press to give. Change the shelf for nest boxes, comfort kits or instrument loans and the page becomes a wildlife, hospice or arts appeal without touching the script.

Charity Campaign Template - website template preview

What's included

10 items
  • Complete one-page appeal: hero with give card and raised-so-far strip, why-now band, Pack a crate, Buy it all supply band, stat band, three stories, where-every-pound-goes trust card, volunteer shifts, donation form and footer
  • Pack a crate: drag, tap or press Enter to put shelf items into a photographed crate, with a running total, a sentence that says what the crate does, per-line add and remove, and an overflow count past twelve items
  • Fill it for me and Buy it all top the crate up to a family set without removing what the visitor already packed
  • One shared amount: the crate total drives the nav pill, the hero card, the form amount, the button label and the Gift Aid line, and they agree in every state
  • Touch-aware dragging: a sideways drag packs, a vertical swipe on the shelf scrolls the page, and a tap adds; on phones the crate stays pinned beside the shelf
  • Donation form with once or monthly, amount presets, a Gift Aid declaration, give-in-someone's-name fields and plain validation, submitting to a thank-you page that reads back the gift
  • Thank-you page that shows the amount, the packed items and the Gift Aid value from the query string, with a share button
  • Works without JavaScript: the shelf becomes a priced list, the crate panel names the family crate, every figure is final in the markup and the form still submits
  • Reduced motion keeps the crate fully usable and drops only the flights, count-ups, reveals and marquee
  • Bundled WebP photography and transparent item cut-outs, regenerated from a one-file manifest

Perfect for

5 use cases
  • Charity appeals and fundraising campaign pages
  • Food banks, pantries and community kitchens
  • Item-based appeals: wildlife trusts buying nest boxes, hospices funding comfort kits, music charities lending instruments
  • Donation landing pages for a dated campaign with a target and a raised-so-far figure
  • Community groups recruiting volunteers for dated shifts

How it works

5 sections

Structure

One script initialises each section, and every initialiser returns early when its markup is missing, so any section can be deleted. The shelf items, their prices and what each one does are data attributes on the buttons in index.html; the script reads them, so changing the appeal means editing markup, not code.

Pack a crate

The crate is one photograph drawn twice: the full crate behind, and a copy clipped to the front wall in front, so packed items sit inside it. Twelve positions are drawn, then a +n more count takes over. Each item is a native button, so a click, a tap, Enter or Space all add it, and a drag is an extra route rather than the only one.

Dragging on a phone

A press commits nothing. The drag only starts once the finger moves more sideways than up or down; a vertical movement is left to the browser, so swiping over the shelf scrolls the page. The crate sits to the right of the shelf at every width and stays pinned beside it on phones, so packing is always a sideways move.

One amount everywhere

The crate total, the hero card, the nav pill and the form share one amount and one frequency. Packing or removing an item rewrites the outcome sentence, the button label and the Gift Aid line together; emptying the crate hands the form back to the family-crate preset.

Without JavaScript or motion

Every figure is final in the markup, the shelf reads as a priced list and the crate panel names the family crate with a link to the form, which submits as an ordinary GET to the thank-you page. Under reduced motion the crate still works; only its flights, the count-ups, reveals and the marquee are dropped.

Plugins ScrollTrigger
Difficulty Intermediate
Smooth scroll Lenis integration
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this template's demo (2 pages, lowest score shown), 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

Can I use it for an appeal that isn't a food bank?

Yes. The page is built for any appeal where a gift buys something concrete. Swap the nine shelf items for nest boxes and hedging for a wildlife trust, comfort kits and night-nurse hours for a hospice, or instrument loans for a music charity, then change the photographs. The crate, totals, outcome sentence and form all read from the items, so the script does not change.

Does the donation form take payments?

No. It collects an amount, once or monthly, name, email, a Gift Aid declaration and optional gift-in-someone's-name details, then submits to the bundled thank-you page. Nothing is charged. Point the form at JustGiving, Enthuse, Stripe Checkout or your existing donation platform, and use thanks.html as that platform's return page if you like.

How do I change what's on the shelf?

Each item is one button in index.html with its price, name and what it does as attributes: meals for food, or a phrase such as a week of nappies. Replace the cut-out image with a transparent WebP of your own item. The fill count on each item sets what Fill it for me packs, so keep those adding up to your headline crate price.

Is the raised-so-far total live?

No. The figure, the bar and the days left are plain markup you update by hand, and they count up when the page loads. Keep them current through the appeal: a stale total on a campaign page does more harm than no total at all. If your platform has a public API you can write the figure in at publish time.

Do I need my own cut-out photos for the items?

For a finished site, yes. The bundled item cut-outs and crate are generated images made for this demo, shown on transparent backgrounds. Any background-removal tool will make your own from product photos; keep them roughly the same size and lighting so the shelf reads as one set, and use the same file names or update the image paths.

Using the earlier design? Download version 1

<!DOCTYPE html>
<html lang="en-GB">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Fill the shelves till spring | Hollins Lane Larder winter appeal</title>
    <meta name="description" content="Hollins Lane Larder is Walkley's community food bank. Our winter appeal needs £38,000 to keep the shelves full until the end of March. Pack a crate or give any amount.">
    <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="icon" href="assets/favicon.svg" type="image/svg+xml">
    <!-- Fonts are self-hosted (assets/fonts/, declared in style.css). The
         headline face and the body 400 are on the first screen, so both
         are preloaded; the other weights load as they are needed. -->
    <link rel="preload" href="assets/fonts/smooch-sans-latin.woff2" as="font" type="font/woff2" crossorigin>
    <link rel="preload" href="assets/fonts/be-vietnam-pro-400-latin.woff2" as="font" type="font/woff2" crossorigin>
    <!-- The hero photograph is the largest paint. Phones in portrait get a
         1200px-wide crop of it, cut so object-position: 64% 30% shows the
         same window as the full frame; everything else gets the full frame. -->
    <link rel="preload" as="image" href="assets/img/hero-sorting-room-phone.webp" media="(max-width: 900px) and (max-aspect-ratio: 3/5)" fetchpriority="high">
    <link rel="preload" as="image" href="assets/img/hero-sorting-room.webp" media="not all and (max-width: 900px) and (max-aspect-ratio: 3/5)" fetchpriority="high">
    <link rel="stylesheet" href="assets/style.css">
</head>
<body>
    <a class="skip" href="#give">Skip to the donation form</a>

    <!-- ===================== NAV ===================== -->
    <header class="nav" data-nav>
        <a class="wordmark" href="#top" aria-label="Hollins Lane Larder, back to the top">
            <svg class="wordmark-mark" viewBox="0 0 40 40" aria-hidden="true" focusable="false">
                <rect x="3" y="15" width="34" height="22" rx="3" fill="currentColor"/>
                <rect x="8" y="5" width="9" height="12" rx="2" fill="currentColor" opacity=".55"/>
                <rect x="20" y="8" width="12" height="9" rx="2" fill="currentColor" opacity=".55"/>
                <rect x="7" y="21" width="26" height="3" rx="1.5" fill="var(--nav-mark-cut)"/>
                <rect x="7" y="28" width="26" height="3" rx="1.5" fill="var(--nav-mark-cut)"/>
            </svg>
            <span class="wordmark-text">Hollins Lane<br>Larder</span>
        </a>
        <nav class="nav-links" aria-label="Sections">
            <a href="#why">The appeal</a>
            <a href="#crate">Pack a crate</a>
            <a href="#stories">Stories</a>
            <a href="#help">Help out</a>
        </nav>
        <div class="nav-pills">
            <a class="pill pill--lilac nav-volunteer" href="#help">Volunteer</a>
            <a class="pill pill--ask" href="#give">Donate<span class="nav-total" data-nav-total hidden></span></a>
        </div>
    </header>

    <main id="top">

        <!-- ===================== HERO =====================
             Cool Earth's arrangement: a full-bleed photograph, the headline
             as big as the first screen allows, and the give card in the hero.
             The card is a real form; without JavaScript it submits to #give. -->
        <section class="hero" data-hero aria-labelledby="hero-title">
            <picture class="hero-pic">
                <source media="(max-width: 900px) and (max-aspect-ratio: 3/5)" srcset="assets/img/hero-sorting-room-phone.webp" width="1200" height="1333">
                <img class="hero-photo" src="assets/img/hero-sorting-room.webp" width="2000" height="1333"
                     alt="Three volunteers sorting bread, pasta and tins at long tables in a bright brick room with tall windows" fetchpriority="high">
            </picture>
            <div class="hero-scrim" aria-hidden="true"></div>

            <div class="hero-inner">
                <div class="hero-copy">
                    <p class="kicker" data-hero-fade>Winter appeal · Walkley, Sheffield</p>
                    <h1 id="hero-title" class="hero-title">
                        <span class="line"><span data-hero-line>Fill the shelves</span></span>
                        <span class="line"><span data-hero-line>till spring.</span></span>
                    </h1>
                    <p class="hero-lede" data-hero-fade>We're Walkley's food bank, open six days a week from the old Co-op on Hollins Lane. Last winter the shelves were bare by February. £38,000 keeps them full until the end of March.</p>
                    <a class="hero-crate-link" href="#crate" data-hero-fade>Or pack a crate yourself <span aria-hidden="true">↓</span></a>
                </div>

                <form class="give-card" action="#give" data-give-card data-hero-fade aria-labelledby="give-card-title">
                    <h2 id="give-card-title" class="visually-hidden">Give to the appeal</h2>
                    <fieldset class="seg">
                        <legend class="visually-hidden">How often</legend>
                        <label><input type="radio" name="hero-frequency" value="once" data-frequency checked><span>Give once</span></label>
                        <label><input type="radio" name="hero-frequency" value="monthly" data-frequency><span>Monthly</span></label>
                    </fieldset>
                    <fieldset class="chips">
                        <legend class="visually-hidden">Choose an amount</legend>
                        <label class="chip"><input type="radio" name="hero-amount" value="10" data-chip data-outcome="puts tins and pasta on the shelf for a week of teas"><span class="chip-amt">£10</span><span class="chip-what">a week of teas</span></label>
                        <label class="chip"><input type="radio" name="hero-amount" value="25" data-chip data-outcome="buys a week of nappies and a month of breakfasts"><span class="chip-amt">£25</span><span class="chip-what">nappies and breakfasts</span></label>
                        <label class="chip"><input type="radio" name="hero-amount" value="45" data-chip data-outcome="packs one full family crate: three days of meals for four" checked><span class="chip-amt">£45</span><span class="chip-what">a full family crate</span></label>
                        <label class="chip"><input type="radio" name="hero-amount" value="100" data-chip data-outcome="cooks Thursday supper for eight families"><span class="chip-amt">£100</span><span class="chip-what">supper for eight families</span></label>
                    </fieldset>
                    <label class="amount-field">
                        <span class="visually-hidden">Amount in pounds</span>
                        <span class="amount-pound" aria-hidden="true">£</span>
                        <input type="number" name="amount" min="1" step="1" inputmode="numeric" value="45" data-amount-input>
                    </label>
                    <p class="outcome" data-outcome aria-live="polite">£45 packs one full family crate: three days of meals for four.</p>
                    <button class="btn btn--ask btn--block" type="submit" data-give-label>Give £45</button>
                </form>
            </div>

            <!-- Raised so far. Every figure is final in the markup; the
                 count-up animates towards data-count-to. Update by hand. -->
            <div class="raised" data-raised>
                <p class="raised-figure"><span class="raised-num" data-count data-count-to="23140" data-count-prefix="£">£23,140</span> <span class="raised-of">raised of £38,000</span></p>
                <div class="raised-bar" role="img" aria-label="61% of the £38,000 target raised"><span class="raised-fill" data-raised-fill style="--raised: 0.61"></span></div>
                <p class="raised-meta"><strong>41 days left</strong> · 1,206 neighbours have given</p>
            </div>
        </section>

        <!-- ===================== WHY NOW ===================== -->
        <section class="why" id="why" aria-labelledby="why-title">
            <div class="why-copy">
                <p class="eyebrow" data-reveal>Why now</p>
                <h2 id="why-title" class="display-l" data-reveal>Last winter the shelves were empty by February.</h2>
                <p data-reveal>412 households came through the door between November and March, nearly twice the winter before. Heating bills went up, the school uniform grant went down, and by the second week of February we were turning people away with a voucher for somewhere else.</p>
                <p data-reveal>This year we want to open every day until the clocks change. That means a full larder from the start, not a scramble in the new year.</p>
                <a class="pill pill--ink" href="#crate" data-reveal>See what a crate holds</a>
            </div>
            <figure class="why-photo">
                <img src="assets/img/why-long-table.webp" width="1100" height="1650" loading="lazy"
                     alt="Volunteers packing loaves, bags of pasta and stacks of tins into clear boxes at a long table under the windows">
            </figure>
        </section>

        <!-- ===================== PACK THE CRATE =====================
             The signature. Each shelf item is a button carrying its price,
             name and what it does. Drag it into the crate, tap it or press
             Enter. Edit the items here; the script reads everything from
             these attributes. data-fill sets how many go in a family crate. -->
        <section class="crate-sec" id="crate" data-crate aria-labelledby="crate-title">
            <div class="crate-head">
                <h2 id="crate-title" class="display-xl">Pack a crate</h2>
                <p class="crate-intro">Drag anything off the shelf into the crate, or tap it. The prices are what we pay at the wholesaler, so your crate is exactly what goes on our shelves.</p>
            </div>

            <div class="crate-grid">
                <div class="shelf" aria-label="The shelf">
                    <ul class="shelf-list" data-shelf>
                        <li class="shelf-slot">
                            <button class="item" type="button" data-item="tomatoes" data-price="3" data-meals="4" data-fill="1" data-name="Chopped tomatoes, 4 tins">
                                <span class="item-pic"><img src="assets/img/item-tomatoes.webp" width="420" height="427" loading="lazy" alt="" draggable="false"></span>
                                <span class="item-name">Chopped tomatoes, 4&nbsp;tins</span>
                                <span class="item-price">£3</span>
                                <span class="visually-hidden">Add to crate</span>
                            </button>
                        </li>
                        <li class="shelf-slot">
                            <button class="item" type="button" data-item="pasta" data-price="2" data-meals="6" data-fill="2" data-name="Penne, 1kg">
                                <span class="item-pic"><img src="assets/img/item-pasta.webp" width="420" height="673" loading="lazy" alt="" draggable="false"></span>
                                <span class="item-name">Penne, 1kg</span>
                                <span class="item-price">£2</span>
                                <span class="visually-hidden">Add to crate</span>
                            </button>
                        </li>
                        <li class="shelf-slot">
                            <button class="item" type="button" data-item="rice" data-price="4" data-meals="10" data-fill="1" data-name="Long-grain rice, 2kg">
                                <span class="item-pic"><img src="assets/img/item-rice.webp" width="420" height="553" loading="lazy" alt="" draggable="false"></span>
                                <span class="item-name">Long-grain rice, 2kg</span>
                                <span class="item-price">£4</span>
                                <span class="visually-hidden">Add to crate</span>
                            </button>
                        </li>
                        <li class="shelf-slot">
                            <button class="item" type="button" data-item="cereal" data-price="3" data-meals="8" data-fill="1" data-name="Breakfast cereal">
                                <span class="item-pic"><img src="assets/img/item-cereal.webp" width="420" height="583" loading="lazy" alt="" draggable="false"></span>
                                <span class="item-name">Breakfast cereal</span>
                                <span class="item-price">£3</span>
                                <span class="visually-hidden">Add to crate</span>
                            </button>
                        </li>
                        <li class="shelf-slot">
                            <button class="item" type="button" data-item="teabags" data-price="5" data-extra="240|cup of tea|cups of tea" data-fill="1" data-name="Teabags, 240">
                                <span class="item-pic"><img src="assets/img/item-teabags.webp" width="420" height="417" loading="lazy" alt="" draggable="false"></span>
                                <span class="item-name">Teabags, 240</span>
                                <span class="item-price">£5</span>
                                <span class="visually-hidden">Add to crate</span>
                            </button>
                        </li>
                        <li class="shelf-slot">
                            <button class="item" type="button" data-item="toiletries" data-price="6" data-extra="1|wash bag|wash bags" data-fill="1" data-name="Wash bag: toothpaste, soap, pads">
                                <span class="item-pic"><img src="assets/img/item-toiletries.webp" width="420" height="331" loading="lazy" alt="" draggable="false"></span>
                                <span class="item-name">Wash bag: toothpaste, soap, pads</span>
                                <span class="item-price">£6</span>
                                <span class="visually-hidden">Add to crate</span>
                            </button>
                        </li>
                        <li class="shelf-slot">
                            <button class="item" type="button" data-item="nappies" data-price="8" data-extra="1|week of nappies|weeks of nappies" data-fill="1" data-name="Nappies, a week's pack">
                                <span class="item-pic"><img src="assets/img/item-nappies.webp" width="420" height="544" loading="lazy" alt="" draggable="false"></span>
                                <span class="item-name">Nappies, a week's pack</span>
                                <span class="item-price">£8</span>
                                <span class="visually-hidden">Add to crate</span>
                            </button>
                        </li>
                        <li class="shelf-slot">
                            <button class="item" type="button" data-item="supper" data-price="12" data-extra="1|Thursday supper for a family|Thursday suppers for a family" data-fill="1" data-name="Thursday supper for a family">
                                <span class="item-pic"><img src="assets/img/item-supper.webp" width="420" height="336" loading="lazy" alt="" draggable="false"></span>
                                <span class="item-name">Thursday supper for a family</span>
                                <span class="item-price">£12</span>
                                <span class="visually-hidden">Add to crate</span>
                            </button>
                        </li>
                        <li class="shelf-slot">
                            <button class="item" type="button" data-item="topup" data-price="20" data-extra="1|week of heat and light|weeks of heat and light" data-fill="0" data-name="Gas and electric top-up">
                                <span class="item-pic"><img src="assets/img/item-topup.webp" width="420" height="260" loading="lazy" alt="" draggable="false"></span>
                                <span class="item-name">Gas and electric top-up</span>
                                <span class="item-price">£20</span>
                                <span class="visually-hidden">Add to crate</span>
                            </button>
                        </li>
                    </ul>
                </div>

                <div class="crate-panel" data-crate-panel>
                    <div class="crate-box" data-crate-box>
                        <img class="crate-back" src="assets/img/crate.webp" width="1000" height="613" loading="lazy" alt="" draggable="false">
                        <div class="crate-slots" data-crate-slots aria-hidden="true"></div>
                        <img class="crate-front" src="assets/img/crate.webp" width="1000" height="613" loading="lazy" alt="" draggable="false" aria-hidden="true">
                        <span class="crate-more" data-crate-more hidden></span>
                    </div>
                    <div class="crate-readout">
                        <p class="crate-total"><span class="visually-hidden">Your crate costs </span><span data-crate-total>£0</span></p>
                        <p class="crate-outcome" data-crate-outcome>Pack an item and this line says what it does.</p>
                        <p class="visually-hidden" data-crate-status role="status"></p>
                        <div class="crate-actions">
                            <button class="btn btn--ink" type="button" data-crate-fill>Fill it for me</button>
                            <a class="btn btn--ask" href="#give" data-crate-give>Give this crate</a>
                        </div>
                    </div>
                    <div class="crate-list-wrap crate-list-wrap--panel" data-crate-list-wrap hidden>
                        <h3 class="crate-list-title">In your crate</h3>
                        <ul class="crate-list" data-crate-list></ul>
                        <button class="text-btn" type="button" data-crate-empty>Empty the crate</button>
                    </div>
                    <!-- Shown without JavaScript in place of the interactive crate. -->
                    <p class="crate-nojs">A full family crate costs <strong>£45</strong>: tomatoes, two bags of pasta, rice, cereal, tea, a wash bag, nappies and a Thursday supper. <a href="#give">Give £45</a>.</p>
                </div>
            </div>

            <div class="crate-list-wrap crate-list-wrap--below" data-crate-list-wrap hidden>
                <h3 class="crate-list-title">In your crate</h3>
                <ul class="crate-list" data-crate-list></ul>
                <button class="text-btn" type="button" data-crate-empty>Empty the crate</button>
            </div>
        </section>

        <!-- ===================== BUY IT ALL ===================== -->
        <section class="buyall" id="buy-it-all" data-buyall aria-labelledby="buyall-title">
            <img class="buyall-photo" src="assets/img/supply-tins.webp" width="1920" height="1280" loading="lazy" data-buyall-photo
                 alt="Stacks of tins and bags of pasta on the sorting table, ready to go on the shelves">
            <div class="buyall-plate" data-reveal>
                <h2 id="buyall-title" class="shop-sign">One family crate.<br>£45.</h2>
                <p>Tomatoes, two bags of pasta, rice, cereal, tea, a wash bag, nappies and a Thursday supper. Three days of meals for a family of four.</p>
                <button class="btn btn--ask btn--square" type="button" data-buy-all>Buy it all</button>
                <a class="btn btn--ask btn--square buyall-nojs" href="#give">Give £45</a>
            </div>
            <div class="marquee" aria-hidden="true" data-marquee>
                <div class="marquee-track" data-marquee-track>
                    <span>Chopped tomatoes</span><span>Penne</span><span>Rice</span><span>Cornflakes</span><span>Teabags</span><span>Toothpaste</span><span>Nappies</span><span>Thursday supper</span><span>Gas top-ups</span>
                </div>
            </div>
        </section>

        <!-- ===================== NUMBERS ===================== -->
        <section class="numbers" aria-label="The larder in numbers">
            <dl class="numbers-list" data-reveal-group>
                <div><dt data-count data-count-to="412">412</dt><dd>households came through the door last winter</dd></div>
                <div><dt data-count data-count-to="86">86</dt><dd>volunteers on the rota, aged 16 to 81</dd></div>
                <div><dt data-count data-count-to="6">6</dt><dd>days a week open, with supper every Thursday</dd></div>
            </dl>
        </section>

        <!-- ===================== STORIES ===================== -->
        <section class="stories" id="stories" aria-labelledby="stories-title">
            <h2 id="stories-title" class="display-l stories-title" data-reveal>Who keeps the shelves full</h2>

            <article class="story" data-story>
                <figure class="story-photo"><img src="assets/img/story-samira.webp" width="1100" height="733" loading="lazy"
                     alt="Samira, smiling in a grey headscarf and volunteer badge, in the storeroom with a colleague sorting behind her"></figure>
                <div class="story-copy" data-reveal>
                    <blockquote class="story-quote"><p>“Tuesday mornings we sort what came in. By lunchtime it looks like a shop.”</p></blockquote>
                    <p class="story-who"><strong>Samira</strong>, runs the Tuesday sorting shift</p>
                    <p>Samira started coming in for the toddler group and stayed for the tea. Three winters on, she trains every new volunteer in how the shelves are laid out, oldest dates at the front.</p>
                </div>
            </article>

            <article class="story story--flip" data-story>
                <figure class="story-photo"><img src="assets/img/story-graham.webp" width="1000" height="1500" loading="lazy"
                     alt="Graham laughing in a green tabard, sitting in the community room with notices on the wall behind him"></figure>
                <div class="story-copy" data-reveal>
                    <blockquote class="story-quote"><p>“Forty plates on a Thursday. Nobody’s asked why they’re there.”</p></blockquote>
                    <p class="story-who"><strong>Graham</strong>, cooks Thursday supper</p>
                    <p>A retired school cook, Graham turns whatever's on the shelf into a hot meal for anyone who walks in. Pasta bake is the favourite. £12 feeds a family of four at his table.</p>
                </div>
            </article>

            <article class="story" data-story>
                <figure class="story-photo"><img src="assets/img/story-maureen.webp" width="1100" height="733" loading="lazy"
                     alt="Maureen talking over mugs of tea at her kitchen table with Kwame, a volunteer in a white t-shirt"></figure>
                <div class="story-copy" data-reveal>
                    <blockquote class="story-quote"><p>“Kwame brings the crate and stops for a brew. Some weeks he’s the only one I see.”</p></blockquote>
                    <p class="story-who"><strong>Maureen</strong>, gets a Saturday delivery</p>
                    <p>Maureen can't manage the hill since her hip operation, so the larder comes to her. Our drivers take forty crates up the valley every Saturday morning.</p>
                </div>
            </article>
        </section>

        <!-- ===================== WHERE EVERY POUND GOES =====================
             Trust as a designed object. Segment widths are --share values;
             keep them in step with the labels. -->
        <section class="pound" id="pound" aria-labelledby="pound-title">
            <div class="trust" data-reveal>
                <div class="trust-badge" aria-hidden="true"><span>92p</span></div>
                <div class="trust-copy">
                    <h2 id="pound-title" class="display-m">92p of every £1 goes on the shelves.</h2>
                    <p>The rest keeps the van on the road and the lights on in the old Co-op. Our accounts are on the Charity Commission register, and the total at the top of this page is updated by hand every Monday.</p>
                    <div class="split" data-split aria-hidden="true">
                        <span class="split-seg split-seg--food" style="--share: 72"></span>
                        <span class="split-seg split-seg--fuel" style="--share: 20"></span>
                        <span class="split-seg split-seg--run" style="--share: 8"></span>
                    </div>
                    <ul class="split-legend">
                        <li><span class="swatch swatch--food" aria-hidden="true"></span>Food on the shelves, 72%</li>
                        <li><span class="swatch swatch--fuel" aria-hidden="true"></span>Gas and electric top-ups, 20%</li>
                        <li><span class="swatch swatch--run" aria-hidden="true"></span>Van and rent, 8%</li>
                    </ul>
                    <p class="trust-fine">Hollins Lane Larder is a registered charity, no. 1187402.</p>
                </div>
            </div>
        </section>

        <!-- ===================== HELP OUT ===================== -->
        <section class="help" id="help" aria-labelledby="help-title">
            <figure class="help-photo">
                <img src="assets/img/help-delivery.webp" width="1100" height="733" loading="lazy"
                     alt="A volunteer and an older man unpacking a crate of lettuce and vegetables in his kitchen">
            </figure>
            <div class="help-copy">
                <h2 id="help-title" class="display-l" data-reveal>Can't give money? Give a morning.</h2>
                <ul class="shifts" data-reveal-group>
                    <li class="shift">
                        <p class="shift-when"><span class="shift-day">Tue</span> 10:00</p>
                        <div class="shift-what"><strong>Sorting shift</strong><span>Unpack the week's donations and check the dates. 4 places left.</span></div>
                        <a class="pill pill--lilac" href="mailto:volunteer@example.org?subject=Tuesday%20sorting%20shift">Sign up</a>
                    </li>
                    <li class="shift">
                        <p class="shift-when"><span class="shift-day">Thu</span> 17:30</p>
                        <div class="shift-what"><strong>Supper crew</strong><span>Chop, serve and wash up with Graham. 2 places left.</span></div>
                        <a class="pill pill--lilac" href="mailto:volunteer@example.org?subject=Thursday%20supper%20crew">Sign up</a>
                    </li>
                    <li class="shift">
                        <p class="shift-when"><span class="shift-day">Sat</span> 09:00</p>
                        <div class="shift-what"><strong>Delivery drivers</strong><span>Your car, our crates, about three hours. Full until January.</span></div>
                        <span class="pill pill--full">Full</span>
                    </li>
                    <li class="shift">
                        <p class="shift-when"><span class="shift-day">Any</span> day</p>
                        <div class="shift-what"><strong>Run a collection</strong><span>At work, school or church. We'll send the shopping list and pick it up.</span></div>
                        <a class="pill pill--lilac" href="mailto:volunteer@example.org?subject=Running%20a%20collection">Ask us</a>
                    </li>
                </ul>
            </div>
        </section>

        <!-- ===================== GIVE =====================
             A front end only: nothing is charged. It submits by GET to
             thanks.html so the thank-you page can show the amount; point
             action at your donation platform instead. -->
        <section class="give" id="give" aria-labelledby="give-title">
            <div class="give-intro">
                <h2 id="give-title" class="display-xl">Give to the appeal</h2>
                <p>Every pound goes on the shelves between now and the end of March. Monthly gifts let us buy in bulk before the price goes up.</p>
                <div class="give-crate" data-give-crate hidden>
                    <p class="give-crate-title">Your crate</p>
                    <ul class="give-crate-list" data-give-crate-list></ul>
                    <a class="text-btn text-btn--light" href="#crate">Change the crate</a>
                </div>
            </div>

            <form class="give-form" action="thanks.html" method="get" data-give-form>
                <fieldset class="seg seg--form">
                    <legend class="field-label">How often</legend>
                    <label><input type="radio" name="frequency" value="once" data-frequency checked><span>Give once</span></label>
                    <label><input type="radio" name="frequency" value="monthly" data-frequency><span>Monthly</span></label>
                </fieldset>

                <fieldset class="chips chips--form">
                    <legend class="field-label">Amount</legend>
                    <label class="chip"><input type="radio" name="preset" value="10" data-chip data-outcome="puts tins and pasta on the shelf for a week of teas"><span class="chip-amt">£10</span></label>
                    <label class="chip"><input type="radio" name="preset" value="25" data-chip data-outcome="buys a week of nappies and a month of breakfasts"><span class="chip-amt">£25</span></label>
                    <label class="chip"><input type="radio" name="preset" value="45" data-chip data-outcome="packs one full family crate: three days of meals for four" checked><span class="chip-amt">£45</span></label>
                    <label class="chip"><input type="radio" name="preset" value="100" data-chip data-outcome="cooks Thursday supper for eight families"><span class="chip-amt">£100</span></label>
                </fieldset>
                <label class="amount-field amount-field--form">
                    <span class="visually-hidden">Amount in pounds</span>
                    <span class="amount-pound" aria-hidden="true">£</span>
                    <input type="number" name="amount" min="1" step="1" inputmode="numeric" value="45" required data-amount-input>
                </label>
                <p class="outcome outcome--form" data-outcome aria-live="polite">£45 packs one full family crate: three days of meals for four.</p>
                <input type="hidden" name="crate" value="" data-crate-field>

                <div class="field-row">
                    <label class="field"><span class="field-label">Your name</span><input type="text" name="name" autocomplete="name" required></label>
                    <label class="field"><span class="field-label">Email</span><input type="email" name="email" autocomplete="email" required></label>
                </div>

                <label class="check">
                    <input type="checkbox" name="giftaid" value="yes" data-giftaid>
                    <span><strong>Add Gift Aid</strong> <span data-giftaid-line>and £45 is worth £56.25 to us</span>. I am a UK taxpayer and understand that if I pay less Income Tax or Capital Gains Tax than the amount of Gift Aid claimed on all my donations in that tax year, it is my responsibility to pay any difference.</span>
                </label>

                <label class="check">
                    <input type="checkbox" name="in_name" value="yes" data-inname>
                    <span><strong>Give this in someone's name.</strong> We'll send them a card saying what you gave.</span>
                </label>
                <div class="inname" data-inname-fields>
                    <div class="field-row">
                        <label class="field"><span class="field-label">Their name</span><input type="text" name="gift_name"></label>
                        <label class="field"><span class="field-label">Their email</span><input type="email" name="gift_email"></label>
                    </div>
                </div>

                <p class="form-error" data-form-error role="alert" hidden></p>
                <button class="btn btn--ask btn--block btn--big" type="submit" data-give-label>Give £45 once</button>
                <p class="form-scope">This demo takes no payment. Connect the form to JustGiving, Enthuse, Stripe or your own donation platform.</p>
            </form>
        </section>
    </main>

    <footer class="footer">
        <div class="footer-mark">
            <p class="footer-word">Hollins Lane<br>Larder</p>
            <p>The old Co-op, Hollins Lane, Walkley, Sheffield S6</p>
        </div>
        <div>
            <p class="footer-label">Open</p>
            <p>Monday to Saturday, 10:00 to 16:00<br>Thursday supper from 17:30</p>
        </div>
        <div>
            <p class="footer-label">The appeal</p>
            <p>Closes 31 March 2027<br>Registered charity no. 1187402</p>
        </div>
        <div>
            <p class="footer-label">Get in touch</p>
            <p><a href="mailto:hello@example.org">hello@example.org</a><br><a href="tel:+441140000000">0114 000 0000</a></p>
        </div>
    </footer>

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.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>
/**
 * Charity Campaign Template: Hollins Lane Larder
 *
 * One script for index.html and thanks.html. Every section initialiser
 * returns early when its markup is missing, so any section can be deleted.
 *
 *   initAppeal     one shared amount and frequency for the hero card, the
 *                  nav pill and the give form, with the outcome line
 *   initCrate      Pack the crate: drag, tap or Enter an item into the
 *                  crate; Fill it for me; the total drives the give amount
 *   initGiveForm   validation, Gift Aid line, gift-in-someone's-name
 *   initThanks     reads the submitted amount and crate on thanks.html
 *   motion         hero intro, count-ups, reveals, marquee, split bar
 *
 * The crate works under reduced motion too (it is a control, not
 * decoration); only its flights and bounces are dropped.
 *
 * @plugins ScrollTrigger
 */

/* Declare every helper and shared variable BEFORE first use. The demo build
   turns function declarations into non-hoisted consts. */

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

    const docEl = document.documentElement;

    /* The thank-you page needs no animation library: run it first, so a
       blocked CDN still shows the donor what they gave. */
    const formatMoney = function (n) {
        const rounded = Math.round(n * 100) / 100;
        return '£' + rounded.toLocaleString('en-GB', {
            minimumFractionDigits: rounded % 1 ? 2 : 0,
            maximumFractionDigits: 2
        });
    };

    const initThanks = function (root) {
        if (!root) return;
        const params = new URLSearchParams(location.search);
        const amount = parseFloat(params.get('amount'));
        if (!(amount > 0)) return;
        const monthly = params.get('frequency') === 'monthly';
        const set = function (sel, text) {
            const el = root.querySelector(sel);
            if (el) el.textContent = text;
            return el;
        };
        set('[data-thanks-amount]', formatMoney(amount) + (monthly ? ' a month' : ''));
        if (params.get('giftaid') === 'yes') {
            const ga = set('[data-thanks-giftaid]', 'With Gift Aid that is ' + formatMoney(amount * 1.25) + ' to the larder' + (monthly ? ', every month.' : '.'));
            if (ga) ga.hidden = false;
        }
        const crate = (params.get('crate') || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean);
        const list = root.querySelector('[data-thanks-crate]');
        if (list && crate.length) {
            crate.forEach(function (line) {
                const li = document.createElement('li');
                li.textContent = line;
                list.appendChild(li);
            });
            const wrap = root.querySelector('[data-thanks-crate-wrap]');
            if (wrap) wrap.hidden = false;
        }
        const giftName = (params.get('gift_name') || '').trim();
        if (params.get('in_name') === 'yes' && giftName) {
            const card = root.querySelector('[data-thanks-gift]');
            set('[data-thanks-gift-name]', giftName);
            if (card) card.hidden = false;
        }
        const name = (params.get('name') || '').trim().split(/\s+/)[0];
        if (name) set('[data-thanks-name]', 'Thank you, ' + name + '.');

        const share = root.querySelector('[data-share]');
        if (share) {
            const url = share.getAttribute('data-share-url') || location.href;
            const absolute = new URL(url, location.href).href;
            share.addEventListener('click', function () {
                if (navigator.share) {
                    navigator.share({ title: document.title, url: absolute }).catch(function () {});
                } else if (navigator.clipboard) {
                    navigator.clipboard.writeText(absolute).then(function () {
                        share.textContent = 'Link copied';
                    });
                }
            });
        }
    };
    initThanks(document.querySelector('[data-thanks]'));

    /* No GSAP (blocked CDN): take the gate off and leave the plain page:
       a priced shelf list and a form that still submits. */
    if (typeof gsap === 'undefined' || typeof ScrollTrigger === 'undefined') {
        docEl.classList.remove('has-js');
        return;
    }
    docEl.classList.add('js-ready');
    gsap.registerPlugin(ScrollTrigger);

    const reducedQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
    const motionOK = function () { return !reducedQuery.matches; };

    /* OPTIONAL Lenis smooth scroll. Copy this helper and its call, or omit
       both. An existing window.lenis belongs to the host page. */
    function initLenis() {
        const wantsSmooth = (new URLSearchParams(location.search).get('smooth')
            || docEl.dataset.smooth) !== 'off';
        if (!wantsSmooth || typeof Lenis === 'undefined' || window.lenis) return null;

        const previousLenis = window.lenis;
        const previousDestroy = window.destroyLenis;
        const hasST = typeof gsap !== 'undefined' && typeof ScrollTrigger !== 'undefined';
        const lenis = new Lenis({ autoRaf: !hasST, allowNestedScroll: true });
        let destroyed = false;
        const tick = function (time) { lenis.raf(time * 1000); };
        const refresh = function () {
            lenis.scrollTo(window.scrollY, { immediate: true, force: true });
        };
        if (hasST) {
            lenis.on('scroll', ScrollTrigger.update);
            gsap.ticker.add(tick);
            gsap.ticker.lagSmoothing(0);
            ScrollTrigger.addEventListener('refresh', refresh);
        }
        const destroy = function () {
            if (destroyed) return;
            destroyed = true;
            if (hasST) {
                gsap.ticker.remove(tick);
                ScrollTrigger.removeEventListener('refresh', refresh);
            }
            lenis.destroy();
            if (window.lenis === lenis) {
                if (previousLenis === undefined) delete window.lenis;
                else window.lenis = previousLenis;
            }
            if (window.destroyLenis === destroy) {
                if (previousDestroy === undefined) delete window.destroyLenis;
                else window.destroyLenis = previousDestroy;
            }
        };
        window.lenis = lenis;
        window.destroyLenis = destroy;
        return destroy;
    }

    const previousContext = window.gsapContext;
    const ctx = gsap.context(function (rootContext) {
        const handlers = [];
        const on = function (el, type, fn, opts) {
            if (!el) return;
            const listener = rootContext.add(null, fn);
            el.addEventListener(type, listener, opts);
            handlers.push(function () { el.removeEventListener(type, listener, opts); });
        };

        const navH = function () {
            const nav = document.querySelector('[data-nav]');
            return nav ? nav.offsetHeight : 0;
        };

        /* In-page links: Lenis scrolls when it is running, the browser
           otherwise. The target receives focus for keyboard users. */
        const scrollToTarget = function (target, focusEl) {
            if (!target) return;
            const offset = -navH() - 8;
            if (window.lenis && typeof window.lenis.scrollTo === 'function') {
                window.lenis.scrollTo(target, { offset: offset, immediate: !motionOK() });
            } else {
                const y = target.getBoundingClientRect().top + window.scrollY + offset;
                window.scrollTo({ top: y, behavior: motionOK() ? 'smooth' : 'auto' });
            }
            if (focusEl) focusEl.focus({ preventScroll: true });
        };
        on(document, 'click', function (e) {
            const link = e.target.closest('a[href^="#"]');
            if (!link || e.defaultPrevented) return;
            const id = link.getAttribute('href').slice(1);
            const target = id ? document.getElementById(id) : null;
            if (!target) return;
            e.preventDefault();
            scrollToTarget(target);
            history.replaceState(null, '', '#' + id);
        });

        // ====================================================================
        // THE APPEAL STATE: one amount and one frequency, shown everywhere
        // ====================================================================

        const appeal = { amount: 45, frequency: 'once', crate: null };
        const amountInputs = Array.prototype.slice.call(document.querySelectorAll('[data-amount-input]'));
        const chips = Array.prototype.slice.call(document.querySelectorAll('[data-chip]'));
        const freqInputs = Array.prototype.slice.call(document.querySelectorAll('[data-frequency]'));
        const outcomes = Array.prototype.slice.call(document.querySelectorAll('[data-outcome]'));
        const giveLabels = Array.prototype.slice.call(document.querySelectorAll('[data-give-label]'));
        const navTotal = document.querySelector('[data-nav-total]');
        const giftAidLine = document.querySelector('[data-giftaid-line]');

        if (amountInputs.length) {
            const start = parseFloat(amountInputs[0].value);
            if (start > 0) appeal.amount = start;
        }

        const chipOutcome = function (amount) {
            for (let i = 0; i < chips.length; i++) {
                if (parseFloat(chips[i].value) === amount) return chips[i].getAttribute('data-outcome');
            }
            return null;
        };

        /* What any amount does. A crate sentence when the amount is the
           crate, a chip's own line when it matches a preset, otherwise a
           rate: about two meals per pound at wholesale prices. */
        const outcomeFor = function (amount) {
            if (!(amount > 0)) return 'Choose an amount and this line says what it does.';
            const monthly = appeal.frequency === 'monthly';
            const every = monthly ? ', every month' : '';
            if (appeal.crate && appeal.crate.total > 0 && amount === appeal.crate.total) {
                return formatMoney(amount) + ' packs ' + appeal.crate.phrase + every + '.';
            }
            const chip = chipOutcome(amount);
            if (chip) return formatMoney(amount) + ' ' + chip + every + '.';
            const crates = Math.floor(amount / 45);
            if (crates >= 1) {
                return formatMoney(amount) + ' packs ' + (crates === 1 ? 'a full family crate' : crates + ' family crates') +
                    (amount % 45 >= 5 ? ' with change for the supper table' : '') + every + '.';
            }
            const meals = Math.max(1, Math.round(amount * 2));
            return formatMoney(amount) + ' puts about ' + meals + (meals === 1 ? ' meal' : ' meals') + ' on the shelves' + every + '.';
        };

        const renderAppeal = function (sourceInput) {
            const a = appeal.amount;
            amountInputs.forEach(function (input) {
                if (input !== sourceInput) input.value = a > 0 ? String(a) : '';
            });
            chips.forEach(function (chip) { chip.checked = parseFloat(chip.value) === a; });
            freqInputs.forEach(function (input) { input.checked = input.value === appeal.frequency; });
            const line = outcomeFor(a);
            outcomes.forEach(function (el) { el.textContent = line; });
            const monthly = appeal.frequency === 'monthly';
            giveLabels.forEach(function (btn) {
                const inForm = !!btn.closest('[data-give-form]');
                if (!(a > 0)) { btn.textContent = 'Give'; return; }
                btn.textContent = 'Give ' + formatMoney(a) + (monthly ? ' a month' : (inForm ? ' once' : ''));
            });
            if (giftAidLine) {
                giftAidLine.textContent = a > 0
                    ? 'and ' + formatMoney(a) + ' is worth ' + formatMoney(a * 1.25) + ' to us'
                    : 'and every £1 is worth £1.25 to us';
            }
            if (navTotal) {
                const crateTotal = appeal.crate ? appeal.crate.total : 0;
                navTotal.hidden = !(crateTotal > 0);
                navTotal.textContent = crateTotal > 0 ? formatMoney(crateTotal) : '';
            }
        };

        const setAmount = function (amount, sourceInput) {
            appeal.amount = amount;
            renderAppeal(sourceInput);
        };

        amountInputs.forEach(function (input) {
            on(input, 'input', function () {
                const v = parseFloat(input.value);
                setAmount(v > 0 ? v : 0, input);
            });
        });
        chips.forEach(function (chip) {
            on(chip, 'change', function () { if (chip.checked) setAmount(parseFloat(chip.value)); });
        });
        freqInputs.forEach(function (input) {
            on(input, 'change', function () {
                if (!input.checked) return;
                appeal.frequency = input.value;
                renderAppeal();
            });
        });

        /* The hero card is a form whose submit carries the amount down to
           the full form rather than leaving the page. */
        const giveCard = document.querySelector('[data-give-card]');
        on(giveCard, 'submit', function (e) {
            e.preventDefault();
            const give = document.getElementById('give');
            const firstField = give ? give.querySelector('input[name="name"]') : null;
            scrollToTarget(give, firstField);
        });

        // ====================================================================
        // PACK THE CRATE
        // ====================================================================

        /* Where packed items sit, as a share of the crate photograph: left
           edge, bottom edge, rotation, stacking. Back row first; the front
           wall (clipped copy of the photo) hides their lower halves.
           Twelve are drawn; more still count, shown as "+n more". */
        const SLOTS = [
            { x: 8, b: 40, r: -4, z: 1 }, { x: 30, b: 41, r: 3, z: 1 }, { x: 52, b: 40, r: -2, z: 1 }, { x: 72, b: 41, r: 4, z: 1 },
            { x: 18, b: 47, r: 5, z: 2 }, { x: 41, b: 48, r: -4, z: 2 }, { x: 62, b: 47, r: 3, z: 2 }, { x: 4, b: 50, r: -6, z: 2 },
            { x: 27, b: 55, r: -3, z: 3 }, { x: 50, b: 56, r: 4, z: 3 }, { x: 72, b: 54, r: -5, z: 3 }, { x: 38, b: 63, r: 2, z: 4 }
        ];

        function initCrate(root) {
            if (!root) return;
            const box = root.querySelector('[data-crate-box]');
            const slotLayer = root.querySelector('[data-crate-slots]');
            if (!box || !slotLayer) return;

            const totalEl = root.querySelector('[data-crate-total]');
            const outcomeEl = root.querySelector('[data-crate-outcome]');
            const statusEl = root.querySelector('[data-crate-status]');
            const moreEl = root.querySelector('[data-crate-more]');
            /* The list is drawn twice: inside the panel on wide screens and
               under the shelf on phones. CSS shows one of them. */
            const listWraps = root.querySelectorAll('[data-crate-list-wrap]');
            const listEls = Array.prototype.slice.call(root.querySelectorAll('[data-crate-list]'));
            const visibleList = function () {
                return listEls.find(function (el) { return el.offsetParent !== null; }) || listEls[0] || null;
            };
            const giveCrate = document.querySelector('[data-give-crate]');
            const giveCrateList = document.querySelector('[data-give-crate-list]');
            const crateField = document.querySelector('[data-crate-field]');

            /* The catalogue is read from the shelf markup. */
            const items = {};
            const order = [];
            root.querySelectorAll('[data-item]').forEach(function (btn) {
                const id = btn.getAttribute('data-item');
                const extra = (btn.getAttribute('data-extra') || '').split('|');
                const img = btn.querySelector('.item-pic img');
                const count = document.createElement('span');
                count.className = 'item-count';
                count.hidden = true;
                count.setAttribute('aria-hidden', 'true');
                btn.appendChild(count);
                items[id] = {
                    id: id,
                    btn: btn,
                    img: img,
                    countEl: count,
                    name: btn.getAttribute('data-name') || id,
                    price: parseFloat(btn.getAttribute('data-price')) || 0,
                    meals: parseFloat(btn.getAttribute('data-meals')) || 0,
                    extra: extra.length === 3 ? { n: parseFloat(extra[0]) || 1, one: extra[1], many: extra[2] } : null,
                    fill: parseInt(btn.getAttribute('data-fill'), 10) || 0
                };
                order.push(id);
            });
            if (!order.length) return;

            const slotEls = SLOTS.map(function (s) {
                const el = document.createElement('div');
                el.className = 'slot';
                el.style.setProperty('--x', s.x + '%');
                el.style.setProperty('--b', s.b + '%');
                el.style.setProperty('--r', s.r + 'deg');
                el.style.setProperty('--z', s.z);
                slotLayer.appendChild(el);
                return el;
            });

            /* units: one entry per packed item, in packing order. slot is
               the index it is drawn in, or -1 when the crate is full. */
            let units = [];

            const countOf = function (id) {
                return units.filter(function (u) { return u.id === id; }).length;
            };
            const freeSlot = function () {
                for (let i = 0; i < slotEls.length; i++) {
                    if (!units.some(function (u) { return u.slot === i; })) return i;
                }
                return -1;
            };

            const phraseFor = function () {
                let meals = 0;
                const parts = [];
                order.forEach(function (id) {
                    const n = countOf(id);
                    if (!n) return;
                    const item = items[id];
                    meals += item.meals * n;
                    if (item.extra) {
                        const qty = item.extra.n * n;
                        parts.push(qty === 1 ? 'a ' + item.extra.one : qty.toLocaleString('en-GB') + ' ' + item.extra.many);
                    }
                });
                if (meals > 0) parts.unshift('about ' + meals + (meals === 1 ? ' meal' : ' meals'));
                if (parts.length < 2) return parts.join('');
                return parts.slice(0, -1).join(', ') + ' and ' + parts[parts.length - 1];
            };

            const drawSlot = function (unit) {
                const el = slotEls[unit.slot];
                const src = items[unit.id].img;
                el.innerHTML = '';
                const img = document.createElement('img');
                img.src = src.currentSrc || src.src;
                img.width = src.naturalWidth || src.width;
                img.height = src.naturalHeight || src.height;
                img.alt = '';
                img.draggable = false;
                el.appendChild(img);
                return img;
            };

            const render = function () {
                const total = units.reduce(function (sum, u) { return sum + items[u.id].price; }, 0);
                const phrase = phraseFor();
                appeal.crate = { total: total, phrase: phrase };
                if (totalEl) totalEl.textContent = formatMoney(total);
                if (outcomeEl) {
                    outcomeEl.textContent = total > 0
                        ? formatMoney(total) + ' packs ' + phrase + '.'
                        : 'Pack an item and this line says what it does.';
                }
                const hiddenCount = units.filter(function (u) { return u.slot < 0; }).length;
                if (moreEl) {
                    moreEl.hidden = hiddenCount === 0;
                    moreEl.textContent = '+' + hiddenCount + ' more';
                }

                const lines = order.filter(function (id) { return countOf(id) > 0; });
                listEls.forEach(function (listEl) {
                    listEl.innerHTML = '';
                    lines.forEach(function (id) {
                        const item = items[id];
                        const n = countOf(id);
                        const li = document.createElement('li');
                        li.className = 'crate-line';
                        li.innerHTML = '<span></span><span class="crate-line-qty"></span>' +
                            '<button type="button" data-line-remove></button><button type="button" data-line-add></button>';
                        li.children[0].textContent = item.name;
                        li.children[1].textContent = '×' + n;
                        const minus = li.children[2];
                        const plus = li.children[3];
                        minus.textContent = '−';
                        plus.textContent = '+';
                        minus.setAttribute('aria-label', 'Remove one ' + item.name);
                        plus.setAttribute('aria-label', 'Add one more ' + item.name);
                        minus.setAttribute('data-id', id);
                        plus.setAttribute('data-id', id);
                        listEl.appendChild(li);
                    });
                });
                listWraps.forEach(function (wrap) { wrap.hidden = lines.length === 0; });

                order.forEach(function (id) {
                    const item = items[id];
                    const n = countOf(id);
                    item.countEl.hidden = n === 0;
                    item.countEl.textContent = '×' + n;
                    item.btn.setAttribute('aria-label', 'Add ' + item.name + ', ' + formatMoney(item.price) +
                        (n ? '. ' + n + ' in your crate' : ''));
                });

                const summary = lines.map(function (id) { return countOf(id) + ' × ' + items[id].name; });
                if (giveCrate) giveCrate.hidden = lines.length === 0;
                if (giveCrateList) {
                    giveCrateList.innerHTML = '';
                    summary.forEach(function (text) {
                        const li = document.createElement('li');
                        li.textContent = text;
                        giveCrateList.appendChild(li);
                    });
                }
                if (crateField) crateField.value = summary.join('; ');

                /* The crate drives the give amount. An emptied crate hands
                   the form back to the default family-crate preset. */
                setAmount(total > 0 ? total : 45);
            };

            const announce = function (text) {
                if (statusEl) statusEl.textContent = text;
            };

            const bob = function () {
                if (!motionOK()) return;
                gsap.fromTo(box, { y: 0 }, { y: 5, duration: 0.09, yoyo: true, repeat: 1, ease: 'power1.out', overwrite: 'auto' });
            };

            /* Fly a ghost image from a rectangle into a slot image. */
            const flyInto = function (slotImg, ghost, fromRect) {
                const to = slotImg.getBoundingClientRect();
                if (!ghost) {
                    ghost = slotImg.cloneNode();
                    ghost.className = 'ghost';
                    ghost.style.width = fromRect.width + 'px';
                    document.body.appendChild(ghost);
                    gsap.set(ghost, { x: fromRect.left, y: fromRect.top, rotation: 0 });
                }
                const startW = parseFloat(ghost.style.width) || fromRect.width;
                gsap.set(slotImg, { autoAlpha: 0 });
                const tl = gsap.timeline({
                    onComplete: function () {
                        ghost.remove();
                        gsap.set(slotImg, { autoAlpha: 1 });
                        gsap.fromTo(slotImg, { y: -10 }, { y: 0, duration: 0.45, ease: 'bounce.out' });
                        bob();
                    }
                });
                tl.to(ghost, { x: to.left, duration: 0.5, ease: 'power1.inOut' }, 0)
                  .to(ghost, { y: to.top, duration: 0.5, ease: 'back.in(1.4)' }, 0)
                  .to(ghost, { scale: to.width / startW, rotation: 0, transformOrigin: '0 0', duration: 0.5, ease: 'power2.in' }, 0);
                return tl;
            };

            const inView = function (el) {
                const r = el.getBoundingClientRect();
                return r.bottom > 0 && r.top < window.innerHeight;
            };

            const add = function (id, opts) {
                const item = items[id];
                if (!item) return;
                opts = opts || {};
                const unit = { id: id, slot: freeSlot() };
                units.push(unit);
                if (unit.slot >= 0) {
                    const slotImg = drawSlot(unit);
                    if (motionOK() && (opts.ghost || (opts.fly && inView(box) && inView(item.img)))) {
                        flyInto(slotImg, opts.ghost || null, item.img.getBoundingClientRect());
                    } else if (opts.ghost) {
                        opts.ghost.remove();
                    }
                } else if (opts.ghost) {
                    gsap.to(opts.ghost, { autoAlpha: 0, scale: 0.6, duration: 0.25, onComplete: function () { opts.ghost.remove(); } });
                    bob();
                }
                render();
                if (!opts.quiet) announce('Added ' + item.name + '. Your crate is ' + formatMoney(appeal.crate.total) + '.');
            };

            const remove = function (id) {
                let idx = -1;
                for (let i = units.length - 1; i >= 0; i--) {
                    if (units[i].id === id) { idx = i; break; }
                }
                if (idx < 0) return;
                const unit = units.splice(idx, 1)[0];
                if (unit.slot >= 0) {
                    const el = slotEls[unit.slot];
                    el.innerHTML = '';
                    /* Promote the first item waiting outside the drawn slots. */
                    const waiting = units.find(function (u) { return u.slot < 0; });
                    if (waiting) {
                        waiting.slot = unit.slot;
                        const img = drawSlot(waiting);
                        if (motionOK()) gsap.fromTo(img, { autoAlpha: 0, y: -14 }, { autoAlpha: 1, y: 0, duration: 0.35, ease: 'power2.out' });
                    }
                }
                render();
                announce('Removed one ' + items[id].name + '. Your crate is ' + formatMoney(appeal.crate.total) + '.');
            };

            const empty = function () {
                units = [];
                slotEls.forEach(function (el) { el.innerHTML = ''; });
                render();
                announce('The crate is empty.');
            };

            /* Fill it for me: top the crate up to the family set, never
               taking out what the visitor already packed. */
            let fillTimers = [];
            const fill = function (fly) {
                fillTimers.forEach(function (t) { t.kill(); });
                fillTimers = [];
                const queue = [];
                order.forEach(function (id) {
                    const need = items[id].fill - countOf(id);
                    for (let i = 0; i < need; i++) queue.push(id);
                });
                if (!queue.length) {
                    announce('Your crate already holds the family set. It is ' + formatMoney(appeal.crate.total) + '.');
                    return;
                }
                const animate = fly && motionOK() && inView(box);
                if (!animate) {
                    queue.forEach(function (id) { add(id, { quiet: true }); });
                } else {
                    queue.forEach(function (id, i) {
                        fillTimers.push(gsap.delayedCall(i * 0.09, function () { add(id, { fly: true, quiet: true }); }));
                    });
                }
                announce('Packed the family crate. Your crate is ' + formatMoney(
                    units.reduce(function (s, u) { return s + items[u.id].price; }, 0) +
                    (animate ? queue.reduce(function (s, id) { return s + items[id].price; }, 0) : 0)
                ) + '.');
            };

            // ---- Pointer drag, with touch intent: sideways packs, up/down scrolls
            let drag = null;
            let suppressClick = false;

            const overCrate = function (x, y) {
                const r = box.getBoundingClientRect();
                const pad = 28;
                return x > r.left - pad && x < r.right + pad && y > r.top - r.height * 0.4 && y < r.bottom + pad;
            };

            const startGhost = function () {
                const rect = drag.item.img.getBoundingClientRect();
                const ghost = drag.item.img.cloneNode();
                ghost.className = 'ghost';
                ghost.style.width = rect.width + 'px';
                ghost.removeAttribute('width');
                ghost.removeAttribute('height');
                document.body.appendChild(ghost);
                drag.offX = drag.x0 - rect.left;
                drag.offY = drag.y0 - rect.top;
                drag.rect = rect;
                drag.ghost = ghost;
                drag.lastX = drag.x0;
                gsap.set(ghost, { x: rect.left, y: rect.top, transformOrigin: '50% 30%' });
                if (motionOK()) gsap.to(ghost, { scale: 1.08, duration: 0.18, ease: 'power2.out' });
                drag.item.btn.style.opacity = '0.55';
            };

            const endDrag = function (dropped) {
                if (!drag) return;
                const d = drag;
                drag = null;
                box.classList.remove('is-over');
                d.item.btn.style.opacity = '';
                if (!d.committed) return;
                suppressClick = true;
                window.setTimeout(function () { suppressClick = false; }, 350);
                if (dropped) {
                    add(d.item.id, { ghost: d.ghost });
                } else if (motionOK()) {
                    gsap.to(d.ghost, {
                        x: d.rect.left, y: d.rect.top, scale: 1, rotation: 0, duration: 0.4, ease: 'back.out(1.6)',
                        onComplete: function () { d.ghost.remove(); }
                    });
                } else {
                    d.ghost.remove();
                }
            };

            on(root, 'pointerdown', function (e) {
                const btn = e.target.closest('[data-item]');
                if (!btn || e.button !== 0 || drag) return;
                drag = {
                    item: items[btn.getAttribute('data-item')],
                    pointerId: e.pointerId,
                    type: e.pointerType,
                    x0: e.clientX,
                    y0: e.clientY,
                    committed: false
                };
            });

            on(window, 'pointermove', function (e) {
                if (!drag || e.pointerId !== drag.pointerId) return;
                const x = typeof e.clientX === 'number' ? e.clientX : drag.x0;
                const y = typeof e.clientY === 'number' ? e.clientY : drag.y0;
                const dx = x - drag.x0;
                const dy = y - drag.y0;
                if (!drag.committed) {
                    if (Math.hypot(dx, dy) < 8) return;
                    /* A touch that moves mostly up or down is a scroll: let
                       the browser have it (touch-action: pan-y on .item). */
                    if (drag.type !== 'mouse' && Math.abs(dy) > Math.abs(dx)) { drag = null; return; }
                    drag.committed = true;
                    startGhost();
                }
                e.preventDefault();
                const tilt = gsap.utils.clamp(-14, 14, (x - drag.lastX) * 0.8);
                drag.lastX = x;
                gsap.set(drag.ghost, { x: x - drag.offX, y: y - drag.offY });
                if (motionOK()) gsap.to(drag.ghost, { rotation: tilt, duration: 0.25, overwrite: 'auto' });
                box.classList.toggle('is-over', overCrate(x, y));
            }, { passive: false });

            on(window, 'pointerup', function (e) {
                if (!drag || e.pointerId !== drag.pointerId) return;
                endDrag(drag.committed && overCrate(e.clientX, e.clientY));
            });
            on(window, 'pointercancel', function (e) {
                if (!drag || e.pointerId !== drag.pointerId) return;
                endDrag(false);
            });

            /* Tap, click, Enter and Space all arrive here. */
            on(root, 'click', function (e) {
                const btn = e.target.closest('[data-item]');
                if (btn) {
                    if (suppressClick) { suppressClick = false; return; }
                    add(btn.getAttribute('data-item'), { fly: true });
                    return;
                }
                const minus = e.target.closest('[data-line-remove]');
                if (minus) {
                    const id = minus.getAttribute('data-id');
                    remove(id);
                    const listEl = visibleList();
                    const again = listEl && listEl.querySelector('[data-line-remove][data-id="' + id + '"]');
                    if (again) again.focus();
                    else if (listEl && listEl.querySelector('button')) listEl.querySelector('button').focus();
                    else if (items[id]) items[id].btn.focus();
                    return;
                }
                const plus = e.target.closest('[data-line-add]');
                if (plus) {
                    const id = plus.getAttribute('data-id');
                    add(id, { fly: true });
                    const listEl = visibleList();
                    const again = listEl && listEl.querySelector('[data-line-add][data-id="' + id + '"]');
                    if (again) again.focus();
                    return;
                }
                if (e.target.closest('[data-crate-empty]')) {
                    empty();
                    const first = items[order[0]];
                    if (first) first.btn.focus();
                }
            });

            on(root.querySelector('[data-crate-fill]'), 'click', function () { fill(true); });
            on(root.querySelector('[data-crate-give]'), 'click', function (e) {
                e.preventDefault();
                const give = document.getElementById('give');
                scrollToTarget(give, give ? give.querySelector('input[name="name"]') : null);
            });

            /* Buy it all, from the supply band: pack the family crate and
               go straight to the form. */
            on(document.querySelector('[data-buy-all]'), 'click', function () {
                fill(false);
                const give = document.getElementById('give');
                scrollToTarget(give, give ? give.querySelector('input[name="name"]') : null);
            });

            render();

            return function cleanupCrate() {
                fillTimers.forEach(function (t) { t.kill(); });
                if (drag && drag.ghost) drag.ghost.remove();
                document.querySelectorAll('.ghost').forEach(function (g) { g.remove(); });
                slotEls.forEach(function (el) { el.remove(); });
                order.forEach(function (id) {
                    items[id].countEl.remove();
                    items[id].btn.removeAttribute('aria-label');
                    items[id].btn.style.opacity = '';
                });
            };
        }

        // ====================================================================
        // THE GIVE FORM
        // ====================================================================

        function initGiveForm(form) {
            if (!form) return;
            form.noValidate = true;
            const error = form.querySelector('[data-form-error]');
            const inName = form.querySelector('[data-inname]');
            const inNameFields = form.querySelector('[data-inname-fields]');

            const toggleInName = function () {
                if (!inName || !inNameFields) return;
                const open = inName.checked;
                inNameFields.classList.toggle('is-open', open);
                if (open && motionOK()) {
                    gsap.fromTo(inNameFields, { height: 0, opacity: 0 }, { height: 'auto', opacity: 1, duration: 0.35, ease: 'power2.out', clearProps: 'height' });
                }
                if (open) {
                    const first = inNameFields.querySelector('input');
                    if (first) first.focus();
                }
            };
            on(inName, 'change', toggleInName);
            if (inName && inName.checked) inNameFields.classList.add('is-open');

            on(form, 'submit', function (e) {
                const problems = [];
                form.querySelectorAll('[aria-invalid]').forEach(function (el) { el.removeAttribute('aria-invalid'); });
                const amountInput = form.querySelector('[data-amount-input]');
                if (!(parseFloat(amountInput.value) > 0)) problems.push([amountInput, 'an amount']);
                const nameInput = form.querySelector('input[name="name"]');
                if (nameInput && !nameInput.value.trim()) problems.push([nameInput, 'your name']);
                const emailInput = form.querySelector('input[name="email"]');
                if (emailInput && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailInput.value.trim())) problems.push([emailInput, 'an email address']);
                if (inName && inName.checked) {
                    const giftName = form.querySelector('input[name="gift_name"]');
                    if (giftName && !giftName.value.trim()) problems.push([giftName, 'their name']);
                }
                if (!problems.length) {
                    if (error) error.hidden = true;
                    return;
                }
                e.preventDefault();
                problems.forEach(function (p) { p[0].setAttribute('aria-invalid', 'true'); });
                if (error) {
                    const names = problems.map(function (p) { return p[1]; });
                    error.textContent = 'Please add ' + (names.length > 1 ? names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1] : names[0]) + '.';
                    error.hidden = false;
                }
                problems[0][0].focus();
            });
        }

        // ====================================================================
        // NAV: oat once the hero has scrolled away
        // ====================================================================

        function initNav(nav, hero) {
            if (!nav) return;
            if (!hero) { nav.classList.add('is-solid'); return; }
            ScrollTrigger.create({
                trigger: hero,
                start: function () { return 'bottom top+=' + (nav.offsetHeight + 1); },
                onEnter: function () { nav.classList.add('is-solid'); },
                onLeaveBack: function () { nav.classList.remove('is-solid'); }
            });
        }

        // ====================================================================
        // MOTION (no-preference only)
        // ====================================================================

        const countUp = function (el) {
            const to = parseFloat(el.getAttribute('data-count-to'));
            if (isNaN(to)) return;
            const prefix = el.getAttribute('data-count-prefix') || '';
            const obj = { v: 0 };
            el.textContent = prefix + '0';
            gsap.to(obj, {
                v: to, duration: to > 1000 ? 1.6 : 1.1, ease: 'power2.out',
                onUpdate: function () { el.textContent = prefix + Math.round(obj.v).toLocaleString('en-GB'); },
                onComplete: function () { el.textContent = prefix + to.toLocaleString('en-GB'); }
            });
        };

        function initHero(root) {
            if (!root) return;
            const lines = root.querySelectorAll('[data-hero-line]');
            const fades = root.querySelectorAll('[data-hero-fade]');
            const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });
            tl.fromTo(lines, { y: 0, yPercent: 105 }, { yPercent: 0, duration: 1, stagger: 0.09 }, 0.1)
              .fromTo(fades, { opacity: 0, y: 22 }, { opacity: 1, y: 0, duration: 0.8, stagger: 0.07 }, 0.35);
            const fill = root.querySelector('[data-raised-fill]');
            if (fill) {
                const share = parseFloat(getComputedStyle(fill).getPropertyValue('--raised')) || 0;
                tl.fromTo(fill, { scaleX: 0 }, { scaleX: share, duration: 1.6, ease: 'power2.inOut' }, 0.5);
            }
            const num = root.querySelector('[data-raised] [data-count]');
            if (num) tl.add(function () { countUp(num); }, 0.5);
        }

        function initReveals() {
            const targets = document.querySelectorAll('[data-reveal]');
            if (targets.length) {
                ScrollTrigger.batch(targets, {
                    start: 'top 88%',
                    once: true,
                    onEnter: function (batch) {
                        gsap.fromTo(batch, { opacity: 0, y: 30 }, { opacity: 1, y: 0, duration: 0.8, stagger: 0.08, ease: 'power3.out', overwrite: true });
                    }
                });
            }
            document.querySelectorAll('[data-reveal-group]').forEach(function (group) {
                ScrollTrigger.create({
                    trigger: group, start: 'top 85%', once: true,
                    onEnter: function () {
                        gsap.fromTo(group.children, { opacity: 0, y: 24 }, { opacity: 1, y: 0, duration: 0.7, stagger: 0.09, ease: 'power3.out' });
                        group.querySelectorAll('[data-count]').forEach(countUp);
                    }
                });
            });
            document.querySelectorAll('[data-story]').forEach(function (story) {
                const img = story.querySelector('.story-photo img');
                if (!img) return;
                ScrollTrigger.create({
                    trigger: story, start: 'top 80%', once: true,
                    onEnter: function () {
                        gsap.fromTo(img, { clipPath: 'inset(100% 0% 0% 0%)', scale: 1.12 },
                            { clipPath: 'inset(0% 0% 0% 0%)', scale: 1, duration: 1.1, ease: 'power3.inOut' });
                    }
                });
            });
            const split = document.querySelector('[data-split]');
            if (split) {
                ScrollTrigger.create({
                    trigger: split, start: 'top 88%', once: true,
                    onEnter: function () {
                        gsap.fromTo(split.children, { scaleX: 0 }, { scaleX: 1, duration: 0.8, stagger: 0.18, ease: 'power3.out' });
                    }
                });
            }
        }

        function initBuyAll(root) {
            if (!root) return;
            const photo = root.querySelector('[data-buyall-photo]');
            if (photo) {
                gsap.fromTo(photo, { scale: 1.14 }, {
                    scale: 1, ease: 'none',
                    scrollTrigger: { trigger: root, start: 'top bottom', end: 'bottom top', scrub: true }
                });
            }
            const track = root.querySelector('[data-marquee-track]');
            if (track) {
                const originals = Array.prototype.slice.call(track.children);
                originals.forEach(function (el) { track.appendChild(el.cloneNode(true)); });
                const loop = gsap.to(track, { xPercent: -50, duration: 38, ease: 'none', repeat: -1 });
                const marquee = root.querySelector('[data-marquee]');
                on(marquee, 'pointerenter', function () { loop.pause(); });
                on(marquee, 'pointerleave', function () { loop.play(); });
                ScrollTrigger.create({
                    trigger: root, start: 'top bottom', end: 'bottom top',
                    onToggle: function (self) { if (self.isActive) loop.play(); else loop.pause(); }
                });
                return function () { originals.length && track.querySelectorAll('span').forEach(function (s, i) { if (i >= originals.length) s.remove(); }); };
            }
        }

        // ====================================================================
        // START
        // ====================================================================

        const cleanups = [];
        cleanups.push(initCrate(document.querySelector('[data-crate]')));
        initGiveForm(document.querySelector('[data-give-form]'));
        initNav(document.querySelector('[data-nav]'), document.querySelector('[data-hero]'));
        if (amountInputs.length) renderAppeal();

        const mm = gsap.matchMedia();
        mm.add({
            isMotion: '(prefers-reduced-motion: no-preference)',
            isReduced: '(prefers-reduced-motion: reduce)'
        }, function (context) {
            const isMotion = context.conditions.isMotion;
            const destroySmoothScroll = isMotion ? initLenis() : null;
            let undoBuyAll = null;
            if (isMotion) {
                initHero(document.querySelector('[data-hero]'));
                initReveals();
                undoBuyAll = initBuyAll(document.querySelector('[data-buyall]'));
            } else {
                gsap.set('[data-hero-fade], [data-reveal], [data-reveal-group] > *', { opacity: 1, clearProps: 'transform' });
            }
            return function cleanupMedia() {
                if (undoBuyAll) undoBuyAll();
                if (destroySmoothScroll) destroySmoothScroll();
            };
        });

        /* Fonts change the hero's measured height; refresh triggers after. */
        if (document.fonts && document.fonts.ready) {
            document.fonts.ready.then(function () { ScrollTrigger.refresh(); });
        }

        const handleUnload = function () { rootContext.revert(); };
        window.addEventListener('beforeunload', handleUnload);
        return function cleanupPage() {
            window.removeEventListener('beforeunload', handleUnload);
            handlers.forEach(function (remove) { remove(); });
            handlers.length = 0;
            cleanups.forEach(function (fn) { if (typeof fn === 'function') fn(); });
            mm.revert();
            if (window.gsapContext === rootContext) {
                docEl.classList.remove('has-js');
                if (previousContext === undefined) delete window.gsapContext;
                else window.gsapContext = previousContext;
            }
        };
    });
    window.gsapContext = ctx;
});
!function(t){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",t):t()}(function(){const t=document.documentElement,e=function(t){const e=Math.round(100*t)/100;return"£"+e.toLocaleString("en-GB",{minimumFractionDigits:e%1?2:0,maximumFractionDigits:2})};if(function(t){if(!t)return;const n=new URLSearchParams(location.search),o=parseFloat(n.get("amount"));if(!(o>0))return;const r="monthly"===n.get("frequency"),a=function(e,n){const o=t.querySelector(e);return o&&(o.textContent=n),o};if(a("[data-thanks-amount]",e(o)+(r?" a month":"")),"yes"===n.get("giftaid")){const t=a("[data-thanks-giftaid]","With Gift Aid that is "+e(1.25*o)+" to the larder"+(r?", every month.":"."));t&&(t.hidden=!1)}const i=(n.get("crate")||"").split(";").map(function(t){return t.trim()}).filter(Boolean),c=t.querySelector("[data-thanks-crate]");if(c&&i.length){i.forEach(function(t){const e=document.createElement("li");e.textContent=t,c.appendChild(e)});const e=t.querySelector("[data-thanks-crate-wrap]");e&&(e.hidden=!1)}const l=(n.get("gift_name")||"").trim();if("yes"===n.get("in_name")&&l){const e=t.querySelector("[data-thanks-gift]");a("[data-thanks-gift-name]",l),e&&(e.hidden=!1)}const u=(n.get("name")||"").trim().split(/\s+/)[0];u&&a("[data-thanks-name]","Thank you, "+u+".");const s=t.querySelector("[data-share]");if(s){const t=s.getAttribute("data-share-url")||location.href,e=new URL(t,location.href).href;s.addEventListener("click",function(){navigator.share?navigator.share({title:document.title,url:e}).catch(function(){}):navigator.clipboard&&navigator.clipboard.writeText(e).then(function(){s.textContent="Link copied"})})}}(document.querySelector("[data-thanks]")),"undefined"==typeof gsap||"undefined"==typeof ScrollTrigger)return void t.classList.remove("has-js");t.classList.add("js-ready"),gsap.registerPlugin(ScrollTrigger);const n=window.matchMedia("(prefers-reduced-motion: reduce)"),o=function(){return!n.matches};const r=window.gsapContext,a=gsap.context(function(n){const a=[],i=function(t,e,o,r){if(!t)return;const i=n.add(null,o);t.addEventListener(e,i,r),a.push(function(){t.removeEventListener(e,i,r)})},c=function(t,e){if(!t)return;const n=-function(){const t=document.querySelector("[data-nav]");return t?t.offsetHeight:0}()-8;if(window.lenis&&"function"==typeof window.lenis.scrollTo)window.lenis.scrollTo(t,{offset:n,immediate:!o()});else{const e=t.getBoundingClientRect().top+window.scrollY+n;window.scrollTo({top:e,behavior:o()?"smooth":"auto"})}e&&e.focus({preventScroll:!0})};i(document,"click",function(t){const e=t.target.closest('a[href^="#"]');if(!e||t.defaultPrevented)return;const n=e.getAttribute("href").slice(1),o=n?document.getElementById(n):null;o&&(t.preventDefault(),c(o),history.replaceState(null,"","#"+n))});const l={amount:45,frequency:"once",crate:null},u=Array.prototype.slice.call(document.querySelectorAll("[data-amount-input]")),s=Array.prototype.slice.call(document.querySelectorAll("[data-chip]")),d=Array.prototype.slice.call(document.querySelectorAll("[data-frequency]")),f=Array.prototype.slice.call(document.querySelectorAll("[data-outcome]")),m=Array.prototype.slice.call(document.querySelectorAll("[data-give-label]")),p=document.querySelector("[data-nav-total]"),h=document.querySelector("[data-giftaid-line]");if(u.length){const t=parseFloat(u[0].value);t>0&&(l.amount=t)}const g=function(t){if(!(t>0))return"Choose an amount and this line says what it does.";const n="monthly"===l.frequency?", every month":"";if(l.crate&&l.crate.total>0&&t===l.crate.total)return e(t)+" packs "+l.crate.phrase+n+".";const o=function(t){for(let e=0;e<s.length;e++)if(parseFloat(s[e].value)===t)return s[e].getAttribute("data-outcome");return null}(t);if(o)return e(t)+" "+o+n+".";const r=Math.floor(t/45);if(r>=1)return e(t)+" packs "+(1===r?"a full family crate":r+" family crates")+(t%45>=5?" with change for the supper table":"")+n+".";const a=Math.max(1,Math.round(2*t));return e(t)+" puts about "+a+(1===a?" meal":" meals")+" on the shelves"+n+"."},y=function(t){const n=l.amount;u.forEach(function(e){e!==t&&(e.value=n>0?String(n):"")}),s.forEach(function(t){t.checked=parseFloat(t.value)===n}),d.forEach(function(t){t.checked=t.value===l.frequency});const o=g(n);f.forEach(function(t){t.textContent=o});const r="monthly"===l.frequency;if(m.forEach(function(t){const o=!!t.closest("[data-give-form]");t.textContent=n>0?"Give "+e(n)+(r?" a month":o?" once":""):"Give"}),h&&(h.textContent=n>0?"and "+e(n)+" is worth "+e(1.25*n)+" to us":"and every £1 is worth £1.25 to us"),p){const t=l.crate?l.crate.total:0;p.hidden=!(t>0),p.textContent=t>0?e(t):""}},v=function(t,e){l.amount=t,y(e)};u.forEach(function(t){i(t,"input",function(){const e=parseFloat(t.value);v(e>0?e:0,t)})}),s.forEach(function(t){i(t,"change",function(){t.checked&&v(parseFloat(t.value))})}),d.forEach(function(t){i(t,"change",function(){t.checked&&(l.frequency=t.value,y())})});const w=document.querySelector("[data-give-card]");i(w,"submit",function(t){t.preventDefault();const e=document.getElementById("give"),n=e?e.querySelector('input[name="name"]'):null;c(e,n)});const b=[{x:8,b:40,r:-4,z:1},{x:30,b:41,r:3,z:1},{x:52,b:40,r:-2,z:1},{x:72,b:41,r:4,z:1},{x:18,b:47,r:5,z:2},{x:41,b:48,r:-4,z:2},{x:62,b:47,r:3,z:2},{x:4,b:50,r:-6,z:2},{x:27,b:55,r:-3,z:3},{x:50,b:56,r:4,z:3},{x:72,b:54,r:-5,z:3},{x:38,b:63,r:2,z:4}];const S=function(t){const e=parseFloat(t.getAttribute("data-count-to"));if(isNaN(e))return;const n=t.getAttribute("data-count-prefix")||"",o={v:0};t.textContent=n+"0",gsap.to(o,{v:e,duration:e>1e3?1.6:1.1,ease:"power2.out",onUpdate:function(){t.textContent=n+Math.round(o.v).toLocaleString("en-GB")},onComplete:function(){t.textContent=n+e.toLocaleString("en-GB")}})};const q=[];var x,E;q.push(function(t){if(!t)return;const n=t.querySelector("[data-crate-box]"),r=t.querySelector("[data-crate-slots]");if(!n||!r)return;const a=t.querySelector("[data-crate-total]"),u=t.querySelector("[data-crate-outcome]"),s=t.querySelector("[data-crate-status]"),d=t.querySelector("[data-crate-more]"),f=t.querySelectorAll("[data-crate-list-wrap]"),m=Array.prototype.slice.call(t.querySelectorAll("[data-crate-list]")),p=function(){return m.find(function(t){return null!==t.offsetParent})||m[0]||null},h=document.querySelector("[data-give-crate]"),g=document.querySelector("[data-give-crate-list]"),y=document.querySelector("[data-crate-field]"),w={},S=[];if(t.querySelectorAll("[data-item]").forEach(function(t){const e=t.getAttribute("data-item"),n=(t.getAttribute("data-extra")||"").split("|"),o=t.querySelector(".item-pic img"),r=document.createElement("span");r.className="item-count",r.hidden=!0,r.setAttribute("aria-hidden","true"),t.appendChild(r),w[e]={id:e,btn:t,img:o,countEl:r,name:t.getAttribute("data-name")||e,price:parseFloat(t.getAttribute("data-price"))||0,meals:parseFloat(t.getAttribute("data-meals"))||0,extra:3===n.length?{n:parseFloat(n[0])||1,one:n[1],many:n[2]}:null,fill:parseInt(t.getAttribute("data-fill"),10)||0},S.push(e)}),!S.length)return;const q=b.map(function(t){const e=document.createElement("div");return e.className="slot",e.style.setProperty("--x",t.x+"%"),e.style.setProperty("--b",t.b+"%"),e.style.setProperty("--r",t.r+"deg"),e.style.setProperty("--z",t.z),r.appendChild(e),e});let x=[];const E=function(t){return x.filter(function(e){return e.id===t}).length},A=function(){for(let t=0;t<q.length;t++)if(!x.some(function(e){return e.slot===t}))return t;return-1},C=function(t){const e=q[t.slot],n=w[t.id].img;e.innerHTML="";const o=document.createElement("img");return o.src=n.currentSrc||n.src,o.width=n.naturalWidth||n.width,o.height=n.naturalHeight||n.height,o.alt="",o.draggable=!1,e.appendChild(o),o},T=function(){const t=x.reduce(function(t,e){return t+w[e.id].price},0),n=function(){let t=0;const e=[];return S.forEach(function(n){const o=E(n);if(!o)return;const r=w[n];if(t+=r.meals*o,r.extra){const t=r.extra.n*o;e.push(1===t?"a "+r.extra.one:t.toLocaleString("en-GB")+" "+r.extra.many)}}),t>0&&e.unshift("about "+t+(1===t?" meal":" meals")),e.length<2?e.join(""):e.slice(0,-1).join(", ")+" and "+e[e.length-1]}();l.crate={total:t,phrase:n},a&&(a.textContent=e(t)),u&&(u.textContent=t>0?e(t)+" packs "+n+".":"Pack an item and this line says what it does.");const o=x.filter(function(t){return t.slot<0}).length;d&&(d.hidden=0===o,d.textContent="+"+o+" more");const r=S.filter(function(t){return E(t)>0});m.forEach(function(t){t.innerHTML="",r.forEach(function(e){const n=w[e],o=E(e),r=document.createElement("li");r.className="crate-line",r.innerHTML='<span></span><span class="crate-line-qty"></span><button type="button" data-line-remove></button><button type="button" data-line-add></button>',r.children[0].textContent=n.name,r.children[1].textContent="×"+o;const a=r.children[2],i=r.children[3];a.textContent="−",i.textContent="+",a.setAttribute("aria-label","Remove one "+n.name),i.setAttribute("aria-label","Add one more "+n.name),a.setAttribute("data-id",e),i.setAttribute("data-id",e),t.appendChild(r)})}),f.forEach(function(t){t.hidden=0===r.length}),S.forEach(function(t){const n=w[t],o=E(t);n.countEl.hidden=0===o,n.countEl.textContent="×"+o,n.btn.setAttribute("aria-label","Add "+n.name+", "+e(n.price)+(o?". "+o+" in your crate":""))});const i=r.map(function(t){return E(t)+" × "+w[t].name});h&&(h.hidden=0===r.length),g&&(g.innerHTML="",i.forEach(function(t){const e=document.createElement("li");e.textContent=t,g.appendChild(e)})),y&&(y.value=i.join("; ")),v(t>0?t:45)},L=function(t){s&&(s.textContent=t)},k=function(){o()&&gsap.fromTo(n,{y:0},{y:5,duration:.09,yoyo:!0,repeat:1,ease:"power1.out",overwrite:"auto"})},P=function(t){const e=t.getBoundingClientRect();return e.bottom>0&&e.top<window.innerHeight},M=function(t,r){const a=w[t];if(!a)return;r=r||{};const i={id:t,slot:A()};if(x.push(i),i.slot>=0){const t=C(i);o()&&(r.ghost||r.fly&&P(n)&&P(a.img))?function(t,e,n){const o=t.getBoundingClientRect();e||((e=t.cloneNode()).className="ghost",e.style.width=n.width+"px",document.body.appendChild(e),gsap.set(e,{x:n.left,y:n.top,rotation:0}));const r=parseFloat(e.style.width)||n.width;gsap.set(t,{autoAlpha:0});const a=gsap.timeline({onComplete:function(){e.remove(),gsap.set(t,{autoAlpha:1}),gsap.fromTo(t,{y:-10},{y:0,duration:.45,ease:"bounce.out"}),k()}});a.to(e,{x:o.left,duration:.5,ease:"power1.inOut"},0).to(e,{y:o.top,duration:.5,ease:"back.in(1.4)"},0).to(e,{scale:o.width/r,rotation:0,transformOrigin:"0 0",duration:.5,ease:"power2.in"},0)}(t,r.ghost||null,a.img.getBoundingClientRect()):r.ghost&&r.ghost.remove()}else r.ghost&&(gsap.to(r.ghost,{autoAlpha:0,scale:.6,duration:.25,onComplete:function(){r.ghost.remove()}}),k());T(),r.quiet||L("Added "+a.name+". Your crate is "+e(l.crate.total)+".")};let B=[];const F=function(t){B.forEach(function(t){t.kill()}),B=[];const r=[];if(S.forEach(function(t){const e=w[t].fill-E(t);for(let n=0;n<e;n++)r.push(t)}),!r.length)return void L("Your crate already holds the family set. It is "+e(l.crate.total)+".");const a=t&&o()&&P(n);a?r.forEach(function(t,e){B.push(gsap.delayedCall(.09*e,function(){M(t,{fly:!0,quiet:!0})}))}):r.forEach(function(t){M(t,{quiet:!0})}),L("Packed the family crate. Your crate is "+e(x.reduce(function(t,e){return t+w[e.id].price},0)+(a?r.reduce(function(t,e){return t+w[e].price},0):0))+".")};let z=null,I=!1;const R=function(t,e){const o=n.getBoundingClientRect();return t>o.left-28&&t<o.right+28&&e>o.top-.4*o.height&&e<o.bottom+28},X=function(t){if(!z)return;const e=z;z=null,n.classList.remove("is-over"),e.item.btn.style.opacity="",e.committed&&(I=!0,window.setTimeout(function(){I=!1},350),t?M(e.item.id,{ghost:e.ghost}):o()?gsap.to(e.ghost,{x:e.rect.left,y:e.rect.top,scale:1,rotation:0,duration:.4,ease:"back.out(1.6)",onComplete:function(){e.ghost.remove()}}):e.ghost.remove())};return i(t,"pointerdown",function(t){const e=t.target.closest("[data-item]");e&&0===t.button&&!z&&(z={item:w[e.getAttribute("data-item")],pointerId:t.pointerId,type:t.pointerType,x0:t.clientX,y0:t.clientY,committed:!1})}),i(window,"pointermove",function(t){if(!z||t.pointerId!==z.pointerId)return;const e="number"==typeof t.clientX?t.clientX:z.x0,r="number"==typeof t.clientY?t.clientY:z.y0,a=e-z.x0,i=r-z.y0;if(!z.committed){if(Math.hypot(a,i)<8)return;if("mouse"!==z.type&&Math.abs(i)>Math.abs(a))return void(z=null);z.committed=!0,function(){const t=z.item.img.getBoundingClientRect(),e=z.item.img.cloneNode();e.className="ghost",e.style.width=t.width+"px",e.removeAttribute("width"),e.removeAttribute("height"),document.body.appendChild(e),z.offX=z.x0-t.left,z.offY=z.y0-t.top,z.rect=t,z.ghost=e,z.lastX=z.x0,gsap.set(e,{x:t.left,y:t.top,transformOrigin:"50% 30%"}),o()&&gsap.to(e,{scale:1.08,duration:.18,ease:"power2.out"}),z.item.btn.style.opacity="0.55"}()}t.preventDefault();const c=gsap.utils.clamp(-14,14,.8*(e-z.lastX));z.lastX=e,gsap.set(z.ghost,{x:e-z.offX,y:r-z.offY}),o()&&gsap.to(z.ghost,{rotation:c,duration:.25,overwrite:"auto"}),n.classList.toggle("is-over",R(e,r))},{passive:!1}),i(window,"pointerup",function(t){z&&t.pointerId===z.pointerId&&X(z.committed&&R(t.clientX,t.clientY))}),i(window,"pointercancel",function(t){z&&t.pointerId===z.pointerId&&X(!1)}),i(t,"click",function(t){const n=t.target.closest("[data-item]");if(n)return I?void(I=!1):void M(n.getAttribute("data-item"),{fly:!0});const r=t.target.closest("[data-line-remove]");if(r){const t=r.getAttribute("data-id");!function(t){let n=-1;for(let e=x.length-1;e>=0;e--)if(x[e].id===t){n=e;break}if(n<0)return;const r=x.splice(n,1)[0];if(r.slot>=0){q[r.slot].innerHTML="";const t=x.find(function(t){return t.slot<0});if(t){t.slot=r.slot;const e=C(t);o()&&gsap.fromTo(e,{autoAlpha:0,y:-14},{autoAlpha:1,y:0,duration:.35,ease:"power2.out"})}}T(),L("Removed one "+w[t].name+". Your crate is "+e(l.crate.total)+".")}(t);const n=p(),a=n&&n.querySelector('[data-line-remove][data-id="'+t+'"]');return void(a?a.focus():n&&n.querySelector("button")?n.querySelector("button").focus():w[t]&&w[t].btn.focus())}const a=t.target.closest("[data-line-add]");if(a){const t=a.getAttribute("data-id");M(t,{fly:!0});const e=p(),n=e&&e.querySelector('[data-line-add][data-id="'+t+'"]');return void(n&&n.focus())}if(t.target.closest("[data-crate-empty]")){x=[],q.forEach(function(t){t.innerHTML=""}),T(),L("The crate is empty.");const t=w[S[0]];t&&t.btn.focus()}}),i(t.querySelector("[data-crate-fill]"),"click",function(){F(!0)}),i(t.querySelector("[data-crate-give]"),"click",function(t){t.preventDefault();const e=document.getElementById("give");c(e,e?e.querySelector('input[name="name"]'):null)}),i(document.querySelector("[data-buy-all]"),"click",function(){F(!1);const t=document.getElementById("give");c(t,t?t.querySelector('input[name="name"]'):null)}),T(),function(){B.forEach(function(t){t.kill()}),z&&z.ghost&&z.ghost.remove(),document.querySelectorAll(".ghost").forEach(function(t){t.remove()}),q.forEach(function(t){t.remove()}),S.forEach(function(t){w[t].countEl.remove(),w[t].btn.removeAttribute("aria-label"),w[t].btn.style.opacity=""})}}(document.querySelector("[data-crate]"))),function(t){if(!t)return;t.noValidate=!0;const e=t.querySelector("[data-form-error]"),n=t.querySelector("[data-inname]"),r=t.querySelector("[data-inname-fields]");i(n,"change",function(){if(!n||!r)return;const t=n.checked;if(r.classList.toggle("is-open",t),t&&o()&&gsap.fromTo(r,{height:0,opacity:0},{height:"auto",opacity:1,duration:.35,ease:"power2.out",clearProps:"height"}),t){const t=r.querySelector("input");t&&t.focus()}}),n&&n.checked&&r.classList.add("is-open"),i(t,"submit",function(o){const r=[];t.querySelectorAll("[aria-invalid]").forEach(function(t){t.removeAttribute("aria-invalid")});const a=t.querySelector("[data-amount-input]");parseFloat(a.value)>0||r.push([a,"an amount"]);const i=t.querySelector('input[name="name"]');i&&!i.value.trim()&&r.push([i,"your name"]);const c=t.querySelector('input[name="email"]');if(c&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.value.trim())&&r.push([c,"an email address"]),n&&n.checked){const e=t.querySelector('input[name="gift_name"]');e&&!e.value.trim()&&r.push([e,"their name"])}if(r.length){if(o.preventDefault(),r.forEach(function(t){t[0].setAttribute("aria-invalid","true")}),e){const t=r.map(function(t){return t[1]});e.textContent="Please add "+(t.length>1?t.slice(0,-1).join(", ")+" and "+t[t.length-1]:t[0])+".",e.hidden=!1}r[0][0].focus()}else e&&(e.hidden=!0)})}(document.querySelector("[data-give-form]")),x=document.querySelector("[data-nav]"),E=document.querySelector("[data-hero]"),x&&(E?ScrollTrigger.create({trigger:E,start:function(){return"bottom top+="+(x.offsetHeight+1)},onEnter:function(){x.classList.add("is-solid")},onLeaveBack:function(){x.classList.remove("is-solid")}}):x.classList.add("is-solid")),u.length&&y();const A=gsap.matchMedia();A.add({isMotion:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(e){const n=e.conditions.isMotion,o=n?function(){if("off"===(new URLSearchParams(location.search).get("smooth")||t.dataset.smooth)||"undefined"==typeof Lenis||window.lenis)return null;const e=window.lenis,n=window.destroyLenis,o="undefined"!=typeof gsap&&"undefined"!=typeof ScrollTrigger,r=new Lenis({autoRaf:!o,allowNestedScroll:!0});let a=!1;const i=function(t){r.raf(1e3*t)},c=function(){r.scrollTo(window.scrollY,{immediate:!0,force:!0})};o&&(r.on("scroll",ScrollTrigger.update),gsap.ticker.add(i),gsap.ticker.lagSmoothing(0),ScrollTrigger.addEventListener("refresh",c));const l=function(){a||(a=!0,o&&(gsap.ticker.remove(i),ScrollTrigger.removeEventListener("refresh",c)),r.destroy(),window.lenis===r&&(void 0===e?delete window.lenis:window.lenis=e),window.destroyLenis===l&&(void 0===n?delete window.destroyLenis:window.destroyLenis=n))};return window.lenis=r,window.destroyLenis=l,l}():null;let r=null;return n?(!function(t){if(!t)return;const e=t.querySelectorAll("[data-hero-line]"),n=t.querySelectorAll("[data-hero-fade]"),o=gsap.timeline({defaults:{ease:"power3.out"}});o.fromTo(e,{y:0,yPercent:105},{yPercent:0,duration:1,stagger:.09},.1).fromTo(n,{opacity:0,y:22},{opacity:1,y:0,duration:.8,stagger:.07},.35);const r=t.querySelector("[data-raised-fill]");if(r){const t=parseFloat(getComputedStyle(r).getPropertyValue("--raised"))||0;o.fromTo(r,{scaleX:0},{scaleX:t,duration:1.6,ease:"power2.inOut"},.5)}const a=t.querySelector("[data-raised] [data-count]");a&&o.add(function(){S(a)},.5)}(document.querySelector("[data-hero]")),function(){const t=document.querySelectorAll("[data-reveal]");t.length&&ScrollTrigger.batch(t,{start:"top 88%",once:!0,onEnter:function(t){gsap.fromTo(t,{opacity:0,y:30},{opacity:1,y:0,duration:.8,stagger:.08,ease:"power3.out",overwrite:!0})}}),document.querySelectorAll("[data-reveal-group]").forEach(function(t){ScrollTrigger.create({trigger:t,start:"top 85%",once:!0,onEnter:function(){gsap.fromTo(t.children,{opacity:0,y:24},{opacity:1,y:0,duration:.7,stagger:.09,ease:"power3.out"}),t.querySelectorAll("[data-count]").forEach(S)}})}),document.querySelectorAll("[data-story]").forEach(function(t){const e=t.querySelector(".story-photo img");e&&ScrollTrigger.create({trigger:t,start:"top 80%",once:!0,onEnter:function(){gsap.fromTo(e,{clipPath:"inset(100% 0% 0% 0%)",scale:1.12},{clipPath:"inset(0% 0% 0% 0%)",scale:1,duration:1.1,ease:"power3.inOut"})}})});const e=document.querySelector("[data-split]");e&&ScrollTrigger.create({trigger:e,start:"top 88%",once:!0,onEnter:function(){gsap.fromTo(e.children,{scaleX:0},{scaleX:1,duration:.8,stagger:.18,ease:"power3.out"})}})}(),r=function(t){if(!t)return;const e=t.querySelector("[data-buyall-photo]");e&&gsap.fromTo(e,{scale:1.14},{scale:1,ease:"none",scrollTrigger:{trigger:t,start:"top bottom",end:"bottom top",scrub:!0}});const n=t.querySelector("[data-marquee-track]");if(n){const e=Array.prototype.slice.call(n.children);e.forEach(function(t){n.appendChild(t.cloneNode(!0))});const o=gsap.to(n,{xPercent:-50,duration:38,ease:"none",repeat:-1}),r=t.querySelector("[data-marquee]");return i(r,"pointerenter",function(){o.pause()}),i(r,"pointerleave",function(){o.play()}),ScrollTrigger.create({trigger:t,start:"top bottom",end:"bottom top",onToggle:function(t){t.isActive?o.play():o.pause()}}),function(){e.length&&n.querySelectorAll("span").forEach(function(t,n){n>=e.length&&t.remove()})}}}(document.querySelector("[data-buyall]"))):gsap.set("[data-hero-fade], [data-reveal], [data-reveal-group] > *",{opacity:1,clearProps:"transform"}),function(){r&&r(),o&&o()}}),document.fonts&&document.fonts.ready&&document.fonts.ready.then(function(){ScrollTrigger.refresh()});const C=function(){n.revert()};return window.addEventListener("beforeunload",C),function(){window.removeEventListener("beforeunload",C),a.forEach(function(t){t()}),a.length=0,q.forEach(function(t){"function"==typeof t&&t()}),A.revert(),window.gsapContext===n&&(t.classList.remove("has-js"),void 0===r?delete window.gsapContext:window.gsapContext=r)}});window.gsapContext=a});
/* ==========================================================================
   Charity Campaign Template: a one-page winter appeal for a community food
   bank, worked through as Hollins Lane Larder in Walkley, Sheffield.

   The look is loud on purpose: big photographs, a compressed display face
   and a ground that changes colour section by section, with one yolk yellow
   kept for everything you press to give. To make it yours, swap the shelf
   items in index.html for what your appeal buys, then repaint --leek.

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

/* Fonts: Smooch Sans and Be Vietnam Pro, self-hosted from assets/fonts/
   (Google Fonts, SIL Open Font License 1.1, texts in assets/fonts/OFL.txt).
   Latin subset only, the same faces and unicode-range the Google Fonts
   stylesheet served, so the page renders identically without a
   render-blocking third-party request. README "Fonts" covers swapping them. */
@font-face {
    font-family: 'Be Vietnam Pro';
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url("fonts/be-vietnam-pro-400-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: 'Be Vietnam Pro';
    font-style: normal;
    font-weight: 600;
    font-display: swap;
    src: url("fonts/be-vietnam-pro-600-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: 'Be Vietnam Pro';
    font-style: normal;
    font-weight: 700;
    font-display: swap;
    src: url("fonts/be-vietnam-pro-700-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: 'Smooch Sans';
    font-style: normal;
    font-weight: 800;
    font-display: swap;
    src: url("fonts/smooch-sans-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: 'Smooch Sans';
    font-style: normal;
    font-weight: 900;
    font-display: swap;
    src: url("fonts/smooch-sans-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 {
    /* Measured pairs (WCAG 2.x, as rendered):
       - white on --leek 4.73:1, so every size of white text on the green is
         solid white; a dimmed white (.88) drops to 4.0 and is not used.
       - ink on --oat 14.96, ink at .72 on oat 6.24 (body copy).
       - ink on --lilac 10.94, ink at .72 on lilac 5.29.
       - ink on --yolk 10.94: the ask buttons. Yolk on leek is 3.13, so yolk
         is only ever a filled button with ink text, never text on green.
       - --leek-deep on oat 6.02 (big figures); white on leek-deep 6.64.
       - white on --tomato 4.02: only the marquee, at 2rem+ heavy (large text).
       - Hero: white over the photo sits on an ink scrim at .74 or more behind
         the copy column, 7.0:1 even over the brightest window.
       Limits: yolk is the ask and nothing else (no yolk grounds, rules or
       decoration). Tomato is a material colour for item art and the split
       bar, never text on oat. Smooch Sans carries display and figures; Be
       Vietnam Pro carries everything you read. */

    --leek: #13853A;
    --leek-rgb: 19, 133, 58;
    --leek-deep: #0E6B2E;
    --leek-deep-rgb: 14, 107, 46;
    --oat: #FBF3E2;
    --oat-rgb: 251, 243, 226;
    --lilac: #D8CBFF;
    --lilac-rgb: 216, 203, 255;
    --ink: #13221A;
    --ink-rgb: 19, 34, 26;
    --yolk: #FFCC1A;
    --yolk-rgb: 255, 204, 26;
    --tomato: #E5452B;
    --tomato-rgb: 229, 69, 43;
    --white: #FFFFFF;
    --white-rgb: 255, 255, 255;

    /* The shelf planks: material colours, never behind text. */
    --plank: #C99459;
    --plank-edge: #8E5E2E;

    --ink-dim: rgba(var(--ink-rgb), 0.72);
    --rule: rgba(var(--ink-rgb), 0.16);

    /* Fixed chrome over photography: the hero scrim is not a palette colour,
       so a repaint of --leek never makes the headline illegible. */
    --scrim-rgb: 19, 34, 26;

    --font-display: 'Smooch Sans', 'Arial Narrow', sans-serif;
    --font-display-weight: 900;
    --font-body: 'Be Vietnam Pro', system-ui, sans-serif;

    --step-hero: clamp(4.6rem, 10.6vw, 10.75rem);
    --step-xl: clamp(4.2rem, 11vw, 11rem);
    --step-l: clamp(3rem, 6vw, 6.25rem);
    --step-m: clamp(2.4rem, 4vw, 4rem);
    --step-body: clamp(1rem, 0.35vw + 0.92rem, 1.125rem);

    --gutter: clamp(1rem, 4vw, 3.5rem);
    --section-y: clamp(4.5rem, 9vw, 8.5rem);
    --nav-h: 76px;
    --radius: 22px;
}

/* --------------------------------------------------------------------------
   BASE. scroll-behavior stays auto: Lenis writes the scroll position every
   frame. In-page links are scrolled by the script instead.
   -------------------------------------------------------------------------- */

*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: auto; -webkit-text-size-adjust: 100%; }
body {
    margin: 0;
    background: var(--oat);
    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; }
h1, h2, h3, p, figure, blockquote, ul, dl, dd { margin: 0; }
ul { padding: 0; list-style: none; }
a { color: inherit; }
button { font: inherit; color: inherit; }
section[id] { scroll-margin-top: var(--nav-h); }

:focus-visible { outline: 3px solid var(--ink); outline-offset: 3px; }
.crate-sec :focus-visible, .give :focus-visible, .hero :focus-visible,
.story--flip :focus-visible, .footer :focus-visible { outline-color: var(--white); }
.give-form :focus-visible, .give-card :focus-visible { outline-color: var(--ink); }

.visually-hidden {
    position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.skip {
    position: absolute; left: var(--gutter); top: -100px; z-index: 100;
    background: var(--yolk); color: var(--ink); padding: 0.7rem 1rem; border-radius: 10px; font-weight: 700;
}
.skip:focus { top: 12px; }

/* Display roles */
.display-xl, .display-l, .display-m, .hero-title, .shop-sign, .crate-total, .numbers dt,
.story-quote, .wordmark-text, .footer-word, .shift-day, .raised-num, .chip-amt, .trust-badge {
    font-family: var(--font-display);
    font-weight: var(--font-display-weight);
    letter-spacing: -0.005em;
}
.display-xl { font-size: var(--step-xl); line-height: 0.8; }
.display-l { font-size: var(--step-l); line-height: 0.88; }
.display-m { font-size: var(--step-m); line-height: 0.92; }
.eyebrow, .kicker {
    font-size: 0.82rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;
}

/* Buttons and pills */
.pill, .btn {
    display: inline-flex; align-items: center; justify-content: center; gap: 0.4em;
    border: 0; border-radius: 999px; text-decoration: none; font-weight: 700; cursor: pointer;
    line-height: 1.1; white-space: nowrap;
}
.pill { padding: 0.72rem 1.15rem; font-size: 0.95rem; }
.pill--lilac { background: var(--lilac); color: var(--ink); }
.pill--ask { background: var(--yolk); color: var(--ink); }
.pill--ink { background: var(--ink); color: var(--oat); }
.pill--full { background: transparent; color: var(--ink-dim); box-shadow: inset 0 0 0 2px var(--rule); cursor: default; }
.btn { padding: 1rem 1.6rem; font-size: 1.02rem; }
.btn--ask { background: var(--yolk); color: var(--ink); }
.btn--ink { background: var(--ink); color: var(--white); border-radius: 8px; }
.btn--square { border-radius: 6px; text-transform: uppercase; letter-spacing: 0.06em; }
.btn--block { width: 100%; }
.btn--big { font-size: 1.2rem; padding: 1.2rem 1.6rem; }
.pill, .btn { transition: transform 0.2s ease, box-shadow 0.2s ease; }
@media (hover: hover) {
    .pill:hover, .btn:hover { transform: translateY(-2px); box-shadow: 0 6px 0 rgba(var(--ink-rgb), 0.18); }
    .pill--full:hover { transform: none; box-shadow: inset 0 0 0 2px var(--rule); }
}
.text-btn {
    background: none; border: 0; padding: 0.3rem 0; cursor: pointer; font-weight: 600;
    text-decoration: underline; text-underline-offset: 0.2em;
}

/* --------------------------------------------------------------------------
   NAV. Absolute (scrolls away) without JavaScript; fixed with it, turning
   oat once the hero has gone.
   -------------------------------------------------------------------------- */

.nav {
    --nav-ink: var(--white);
    --nav-mark-cut: var(--leek);
    position: absolute; inset: 0 0 auto 0; z-index: 50;
    height: var(--nav-h);
    display: flex; align-items: center; gap: 2rem;
    padding: 0 var(--gutter);
    color: var(--nav-ink);
    transition: background-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease;
}
.has-js .nav { position: fixed; }
.nav.is-solid { --nav-ink: var(--ink); background: var(--oat); box-shadow: 0 1px 0 var(--rule); }
.wordmark { display: flex; align-items: center; gap: 0.6rem; text-decoration: none; color: inherit; }
.wordmark-mark { width: 38px; height: 38px; flex: none; }
.wordmark-text { font-size: 1.55rem; line-height: 0.82; text-transform: uppercase; }
.nav-links { display: flex; gap: 1.6rem; margin-left: auto; }
.nav-links a { text-decoration: none; font-weight: 600; font-size: 0.97rem; }
.nav-links a:hover { text-decoration: underline; text-underline-offset: 0.3em; }
.nav-pills { display: flex; gap: 0.6rem; }
.nav-total::before { content: ' '; }

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

.hero {
    position: relative;
    min-height: min(100svh, 1000px);
    display: grid; grid-template-rows: 1fr auto;
    background: var(--leek-deep);
    color: var(--white);
    isolation: isolate;
}
/* The <picture> only chooses the file. It covers the hero out of flow so
   neither it nor its <source> takes a grid row; the img sizes as before. */
.hero-pic { position: absolute; inset: 0; }
.hero-photo {
    position: absolute; inset: 0; z-index: -2;
    width: 100%; height: 100%; object-fit: cover; object-position: 58% 40%;
}
.hero-scrim {
    position: absolute; inset: 0; z-index: -1;
    background:
        linear-gradient(180deg, rgba(var(--scrim-rgb), 0.55) 0, rgba(var(--scrim-rgb), 0) 20%),
        linear-gradient(90deg, rgba(var(--scrim-rgb), 0.8) 0, rgba(var(--scrim-rgb), 0.74) 38%, rgba(var(--scrim-rgb), 0.28) 60%, rgba(var(--scrim-rgb), 0.06) 80%);
}
.hero-inner {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(320px, 400px);
    gap: clamp(2rem, 5vw, 6rem);
    align-items: center;
    padding: calc(var(--nav-h) + clamp(1.5rem, 4vh, 3rem)) var(--gutter) clamp(2rem, 5vh, 3.5rem);
}
.hero-copy { max-width: 64rem; }
.kicker { margin-bottom: 1.1rem; }
.hero-title { font-size: var(--step-hero); line-height: 0.84; margin: 0 0 1.6rem; }
.hero-title .line { display: block; overflow: hidden; padding-bottom: 0.06em; margin-bottom: -0.06em; }
.hero-title .line > span { display: inline-block; }
.hero-lede { max-width: 34rem; font-size: clamp(1.05rem, 0.5vw + 0.95rem, 1.25rem); line-height: 1.5; margin-bottom: 1.4rem; }
.hero-crate-link { font-weight: 700; text-decoration: underline; text-underline-offset: 0.25em; text-decoration-thickness: 2px; }

/* The give card (hero) and the full form share these controls */
.give-card {
    background: var(--white); color: var(--ink);
    border-radius: var(--radius);
    padding: 1.3rem;
    box-shadow: 0 24px 60px rgba(var(--ink-rgb), 0.28);
    display: grid; gap: 0.85rem;
}
fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
.seg { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; background: var(--oat); border-radius: 999px; padding: 4px; }
.seg legend.field-label { grid-column: 1 / -1; }
.seg label { position: relative; }
.seg input, .chip input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
.seg span {
    display: block; text-align: center; padding: 0.6rem 0.5rem; border-radius: 999px;
    font-weight: 700; font-size: 0.95rem; color: var(--ink-dim);
}
.seg input:checked + span { background: var(--ink); color: var(--white); }
.seg label:has(input:focus-visible) span, .chip:has(input:focus-visible) {
    outline: 3px solid var(--ink); outline-offset: 2px;
}
.chips { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.chip {
    position: relative; display: grid; gap: 0.1rem;
    border-radius: 14px; padding: 0.55rem 0.8rem 0.65rem;
    box-shadow: inset 0 0 0 2px var(--rule); cursor: pointer;
}
.chip-amt { font-size: 2.1rem; line-height: 0.9; }
.chip-what { font-size: 0.8rem; line-height: 1.25; color: var(--ink-dim); }
.chip:has(input:checked) { background: var(--yolk); box-shadow: inset 0 0 0 2px var(--yolk); }
.chip:has(input:checked) .chip-what { color: var(--ink); }
.amount-field {
    display: flex; align-items: center; gap: 0.3rem;
    border-radius: 14px; box-shadow: inset 0 0 0 2px var(--rule); padding: 0.2rem 1rem;
}
.amount-field:focus-within { box-shadow: inset 0 0 0 3px var(--ink); }
.amount-pound { font-family: var(--font-display); font-weight: 900; font-size: 2.2rem; }
.amount-field input {
    border: 0; background: transparent; width: 100%; min-width: 0;
    font-family: var(--font-display); font-weight: 900; font-size: 2.6rem; color: var(--ink); padding: 0.1rem 0;
    -moz-appearance: textfield; appearance: textfield;
}
.amount-field input:focus { outline: none; }
.amount-field input::-webkit-outer-spin-button, .amount-field input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.outcome { font-size: 0.92rem; line-height: 1.4; color: var(--ink-dim); min-height: 2.6em; }

/* Raised so far */
.raised {
    display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center;
    gap: 1.2rem clamp(1.2rem, 3vw, 3rem);
    background: var(--leek); color: var(--white);
    padding: 1.05rem var(--gutter);
}
.raised-figure { display: flex; align-items: baseline; gap: 0.6rem; white-space: nowrap; }
.raised-num { font-size: clamp(2.4rem, 3.4vw, 3.4rem); line-height: 0.9; }
.raised-of { font-weight: 600; }
.raised-bar { height: 14px; border-radius: 999px; background: rgba(var(--ink-rgb), 0.28); overflow: hidden; }
.raised-fill { display: block; height: 100%; background: var(--white); border-radius: inherit; transform-origin: left center; transform: scaleX(var(--raised)); }
.raised-meta { white-space: nowrap; }

/* --------------------------------------------------------------------------
   WHY NOW (lilac)
   -------------------------------------------------------------------------- */

.why { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); background: var(--lilac); }
.why-copy {
    display: grid; align-content: center; gap: 1.2rem; justify-items: start;
    padding: var(--section-y) clamp(1.5rem, 5vw, 5rem) var(--section-y) var(--gutter);
}
.why-copy p:not(.eyebrow) { max-width: 36rem; color: var(--ink-dim); }
.why-copy .display-l { max-width: 13ch; margin-bottom: 0.4rem; }
.why-photo { position: relative; min-height: 640px; }
.why-photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 50% 62%; }

/* --------------------------------------------------------------------------
   PACK THE CRATE (leek). The signature.
   -------------------------------------------------------------------------- */

.crate-sec { background: var(--leek); color: var(--white); padding: var(--section-y) var(--gutter); }
.crate-head {
    display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); align-items: end;
    gap: 1rem 3rem; margin-bottom: clamp(2rem, 4vw, 3.5rem);
}
.crate-intro { max-width: 32rem; font-size: clamp(1.05rem, 0.4vw + 0.95rem, 1.2rem); padding-bottom: 0.5rem; }

.crate-grid {
    display: grid;
    grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
    grid-template-areas: "shelf panel";
    gap: 1.5rem clamp(1.5rem, 3.5vw, 3.5rem);
    align-items: start;
}
.shelf { grid-area: shelf; background: var(--leek-deep); border-radius: calc(var(--radius) + 6px); padding: 1.5rem clamp(0.9rem, 2vw, 1.8rem) 0.6rem; }
.crate-panel { grid-area: panel; position: sticky; top: calc(var(--nav-h) + 1rem); }
.crate-list-wrap--panel { margin-top: 1.2rem; }
.crate-list-wrap--below { display: none; margin-top: 1.6rem; }

.shelf-list {
    --pic-h: clamp(110px, 10.5vw, 152px);
    --shelf-gap: clamp(0.6rem, 1.6vw, 1.4rem);
    display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
    column-gap: var(--shelf-gap); row-gap: 1.6rem;
}
.shelf-slot { position: relative; }
/* The plank runs under every item and bridges the gap to its neighbour. */
.shelf-slot::before {
    content: ''; position: absolute; z-index: 0;
    top: calc(var(--pic-h) - 2px); left: calc(var(--shelf-gap) / -2 - 1px); right: calc(var(--shelf-gap) / -2 - 1px);
    height: 16px;
    background: linear-gradient(180deg, var(--plank) 0 70%, var(--plank-edge) 70% 100%);
    box-shadow: 0 10px 14px -8px rgba(var(--ink-rgb), 0.55);
}
.shelf-slot:nth-child(3n + 1)::before { left: calc(-1 * clamp(0.9rem, 2vw, 1.8rem)); border-radius: 0; }
.shelf-slot:nth-child(3n)::before { right: calc(-1 * clamp(0.9rem, 2vw, 1.8rem)); }
.item {
    position: relative; z-index: 1;
    display: grid; justify-items: center; gap: 0.45rem; width: 100%;
    background: none; border: 0; padding: 0 0 0.6rem; color: var(--white);
    cursor: grab; text-align: center;
    touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
    border-radius: 14px;
}
.item:active { cursor: grabbing; }
.item-pic { height: var(--pic-h); width: 100%; display: flex; align-items: flex-end; justify-content: center; padding: 0 6%; }
.item-pic img {
    max-height: calc(var(--pic-h) - 8px); width: auto; max-width: 100%;
    filter: drop-shadow(0 6px 6px rgba(var(--ink-rgb), 0.35));
    -webkit-user-drag: none; pointer-events: none;
    transition: transform 0.25s cubic-bezier(.3, 1.6, .5, 1);
}
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
    .item:hover .item-pic img { transform: translateY(-6px) rotate(-2deg); }
}
.item-name { font-size: 0.86rem; font-weight: 600; line-height: 1.25; margin-top: 0.85rem; min-height: 2.5em; display: flex; align-items: center; }
.item-price {
    background: var(--yolk); color: var(--ink); font-weight: 700; font-size: 0.95rem;
    border-radius: 999px; padding: 0.2rem 0.75rem;
}
.item-count {
    position: absolute; top: 0.2rem; right: 8%;
    min-width: 2rem; height: 2rem; padding: 0 0.45rem; border-radius: 999px;
    display: grid; place-items: center;
    background: var(--white); color: var(--ink); font-weight: 700; font-size: 0.9rem;
}
.item-count[hidden] { display: none; }

/* The crate: one photograph drawn twice. The front copy is clipped to the
   front wall (from --front-top down), so packed items sit inside it. */
.crate-box {
    --front-top: 37.4%;
    position: relative; aspect-ratio: 1000 / 613; margin-top: 12%; max-width: 36rem;
    border-radius: 18px;
}
.crate-box img { -webkit-user-drag: none; user-select: none; }
.crate-back, .crate-front { position: absolute; inset: 0; width: 100%; height: 100%; }
.crate-back { filter: drop-shadow(0 18px 18px rgba(var(--ink-rgb), 0.35)); }
.crate-front { clip-path: inset(var(--front-top) 0 0 0); z-index: 3; pointer-events: none; }
.crate-slots { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
.slot { position: absolute; left: var(--x); bottom: var(--b); width: var(--w, 22%); transform: rotate(var(--r)); transform-origin: 50% 100%; z-index: var(--z); }
.slot img { width: 100%; height: auto; filter: drop-shadow(0 4px 5px rgba(var(--ink-rgb), 0.35)); }
.crate-box.is-over { outline: 3px dashed rgba(var(--white-rgb), 0.85); outline-offset: 10px; }
.crate-more {
    position: absolute; z-index: 4; right: 4%; top: 30%;
    background: var(--ink); color: var(--white); border-radius: 999px; padding: 0.3rem 0.8rem; font-weight: 700; font-size: 0.9rem;
}
.crate-more[hidden] { display: none; }

.crate-readout { display: grid; gap: 0.9rem; margin-top: 1.4rem; }
.crate-total { font-family: var(--font-display); font-weight: 900; font-size: clamp(4.6rem, 7.5vw, 7.5rem); line-height: 0.8; font-variant-numeric: tabular-nums; }
.crate-outcome { font-size: 1.05rem; line-height: 1.45; min-height: 2.9em; max-width: 30rem; }
.crate-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; }
.crate-nojs { display: none; }

.crate-list-title { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 0.6rem; }
.crate-list { display: flex; flex-wrap: wrap; gap: 0.45rem; margin-bottom: 0.8rem; }
.crate-line {
    display: inline-flex; align-items: center; gap: 0.35rem;
    background: rgba(var(--ink-rgb), 0.24); border-radius: 999px; padding: 0.2rem 0.25rem 0.2rem 0.75rem;
    font-size: 0.82rem; font-weight: 600;
}
.crate-line-qty { font-variant-numeric: tabular-nums; }
.crate-line button {
    width: 1.75rem; height: 1.75rem; border-radius: 999px; border: 0; cursor: pointer;
    background: var(--white); color: var(--ink); font-weight: 700; font-size: 1.05rem; line-height: 1;
    display: grid; place-items: center;
}
.crate-list-wrap[hidden] { display: none; }

/* Without JavaScript (or with the CDN blocked) the shelf is a priced list
   and the crate panel shows the family crate instead. */
html:not(.has-js) .crate-box,
html:not(.has-js) .crate-readout,
html:not(.has-js) .crate-list-wrap,
html:not(.has-js) .buyall [data-buy-all] { display: none; }
html:not(.has-js) .crate-nojs { display: block; font-size: 1.15rem; }
html:not(.has-js) .crate-nojs a { font-weight: 700; }
html:not(.has-js) .item { cursor: default; }
.buyall-nojs { display: none; }
html:not(.has-js) .buyall-nojs { display: inline-flex; }

/* The ghost that follows a drag or flies into the crate */
.ghost {
    position: fixed; left: 0; top: 0; z-index: 90; pointer-events: none;
    filter: drop-shadow(0 16px 14px rgba(var(--ink-rgb), 0.4));
    will-change: transform;
}

/* --------------------------------------------------------------------------
   BUY IT ALL (full-bleed supply photograph)
   -------------------------------------------------------------------------- */

.buyall {
    position: relative; overflow: hidden;
    min-height: clamp(600px, 92vh, 880px);
    display: grid; align-items: start;
    padding: var(--section-y) var(--gutter) calc(var(--section-y) + 4rem);
    background: var(--ink);
}
.buyall-photo { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 50% 55%; transform-origin: 60% 50%; }
.buyall-plate {
    position: relative; z-index: 1; max-width: 32rem;
    background: var(--ink); color: var(--oat);
    padding: clamp(1.5rem, 3vw, 2.4rem);
    display: grid; gap: 1.2rem; justify-items: start;
}
.shop-sign { font-size: clamp(3.6rem, 6.4vw, 6.6rem); line-height: 0.84; text-transform: uppercase; }
.marquee {
    position: absolute; left: 0; right: 0; bottom: 0; z-index: 1;
    background: var(--tomato); color: var(--white); overflow: hidden;
    padding: 0.7rem 0;
}
.marquee-track { display: flex; width: max-content; }
.marquee-track span {
    font-family: var(--font-display); font-weight: 900; font-size: clamp(2rem, 3vw, 2.6rem); line-height: 1;
    text-transform: uppercase; padding: 0 1.4rem; white-space: nowrap;
    }

/* --------------------------------------------------------------------------
   NUMBERS (Felix stat band)
   -------------------------------------------------------------------------- */

.numbers { padding: var(--section-y) var(--gutter); background: var(--oat); }
.numbers-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2rem; }
.numbers dt { font-size: clamp(6.5rem, 15vw, 14rem); line-height: 0.78; color: var(--leek-deep); font-variant-numeric: tabular-nums; }
.numbers dd { font-size: clamp(1.05rem, 0.4vw + 0.95rem, 1.25rem); max-width: 18rem; margin-top: 1rem; font-weight: 600; }

/* --------------------------------------------------------------------------
   STORIES: half-page photographs that bleed to the edge
   -------------------------------------------------------------------------- */

.stories { background: var(--oat); padding-top: 0; }
.stories-title { padding: 0 var(--gutter) clamp(2rem, 4vw, 3rem); max-width: 16ch; box-sizing: content-box; }
.story { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: clamp(520px, 44vw, 700px); }
.story-photo { position: relative; overflow: hidden; }
.story-photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.story:nth-of-type(1) .story-photo img { object-position: 58% 40%; }
.story--flip .story-photo img { object-position: 50% 30%; }
.story-copy { display: grid; align-content: center; gap: 1.1rem; padding: clamp(2.5rem, 6vw, 6rem) clamp(1.5rem, 5vw, 5.5rem); }
.story-quote { font-size: clamp(2.6rem, 4.3vw, 4.6rem); line-height: 0.92; max-width: 15ch; }
.story-who { font-size: 1.05rem; }
.story-copy > p:last-child { max-width: 32rem; color: var(--ink-dim); }
.story--flip { background: var(--leek); color: var(--white); }
.story--flip .story-photo { order: 2; }
.story--flip .story-copy > p:last-child { color: var(--white); }

/* --------------------------------------------------------------------------
   WHERE EVERY POUND GOES (trust as an object)
   -------------------------------------------------------------------------- */

.pound { background: var(--lilac); padding: var(--section-y) var(--gutter); }
.trust {
    max-width: 1080px; margin: 0 auto;
    background: var(--oat); border-radius: calc(var(--radius) + 10px);
    padding: clamp(1.8rem, 4.5vw, 3.6rem);
    display: grid; grid-template-columns: auto minmax(0, 1fr); gap: clamp(1.5rem, 4vw, 3.5rem); align-items: start;
    box-shadow: 0 2px 0 rgba(var(--ink-rgb), 0.08);
}
.trust-badge {
    width: clamp(140px, 15vw, 200px); aspect-ratio: 1; border-radius: 50%;
    display: grid; place-items: center;
    box-shadow: inset 0 0 0 12px var(--leek); color: var(--leek-deep);
    font-size: clamp(3.6rem, 5vw, 5rem);
}
.trust-copy { display: grid; gap: 1.1rem; }
.trust-copy > p { color: var(--ink-dim); max-width: 40rem; }
.split { display: flex; height: 48px; border-radius: 12px; overflow: hidden; gap: 4px; }
.split-seg { flex: 0 0 calc(var(--share) * 1% - 3px); transform-origin: left center; }
.split-seg--food, .swatch--food { background: var(--leek); }
.split-seg--fuel, .swatch--fuel { background: var(--tomato); }
.split-seg--run, .swatch--run { background: var(--ink); }
.split-legend { display: flex; flex-wrap: wrap; gap: 0.4rem 1.6rem; font-weight: 600; font-size: 0.95rem; }
.split-legend li { display: flex; align-items: center; gap: 0.5rem; }
.swatch { width: 14px; height: 14px; border-radius: 4px; flex: none; }
.trust-fine { font-size: 0.9rem; }

/* --------------------------------------------------------------------------
   HELP OUT
   -------------------------------------------------------------------------- */

.help {
    display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
    gap: clamp(2rem, 5vw, 5rem); padding: var(--section-y) var(--gutter); align-items: stretch;
}
.help-photo { position: relative; min-height: 420px; border-radius: var(--radius); overflow: hidden; }
.help-photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 40% 50%; }
.help-copy .display-l { max-width: 12ch; margin-bottom: 2rem; }
.shifts { border-top: 2px solid var(--ink); }
.shift {
    display: grid; grid-template-columns: 7.5rem minmax(0, 1fr) auto; gap: 0.6rem 1.5rem; align-items: center;
    padding: 1.15rem 0; border-bottom: 2px solid var(--rule);
}
.shift-when { font-weight: 600; font-size: 0.95rem; display: flex; align-items: baseline; gap: 0.45rem; }
.shift-day { font-size: 2.7rem; line-height: 0.9; color: var(--leek-deep); }
.shift-what { display: grid; gap: 0.15rem; }
.shift-what span { color: var(--ink-dim); font-size: 0.97rem; }

/* --------------------------------------------------------------------------
   GIVE (leek) and the form
   -------------------------------------------------------------------------- */

.give {
    background: var(--leek); color: var(--white);
    display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
    gap: clamp(2rem, 5vw, 5rem); padding: var(--section-y) var(--gutter); align-items: start;
}
.give-intro { display: grid; gap: 1.4rem; position: sticky; top: calc(var(--nav-h) + 2rem); }
.give-intro > p { max-width: 28rem; font-size: 1.1rem; }
.give-crate { display: grid; gap: 0.6rem; justify-items: start; border-top: 2px solid rgba(var(--white-rgb), 0.5); padding-top: 1.2rem; }
.give-crate[hidden] { display: none; }
.give-crate-title { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
.give-crate-list { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.give-crate-list li { background: rgba(var(--ink-rgb), 0.24); border-radius: 999px; padding: 0.3rem 0.8rem; font-size: 0.9rem; font-weight: 600; }
.text-btn--light { color: var(--white); }

.give-form {
    background: var(--oat); color: var(--ink);
    border-radius: calc(var(--radius) + 6px);
    padding: clamp(1.4rem, 3.5vw, 2.6rem);
    display: grid; gap: 1.15rem;
}
.field-label { display: block; font-size: 0.85rem; font-weight: 700; margin-bottom: 0.4rem; }
.seg--form { background: rgba(var(--ink-rgb), 0.07); }
.seg--form legend { padding: 0; }
.chips--form { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.chips--form .chip { justify-items: center; padding: 0.7rem 0.4rem; }
.chips--form legend { grid-column: 1 / -1; }
.amount-field--form { background: var(--white); }
.outcome--form { font-size: 1rem; color: var(--ink); font-weight: 600; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; }
.field input {
    width: 100%; font: inherit; color: var(--ink); background: var(--white);
    border: 0; box-shadow: inset 0 0 0 2px var(--rule); border-radius: 12px; padding: 0.8rem 0.9rem;
}
.field input:focus { outline: none; box-shadow: inset 0 0 0 3px var(--ink); }
.field input[aria-invalid="true"] { box-shadow: inset 0 0 0 3px var(--tomato); }
.check { display: grid; grid-template-columns: auto 1fr; gap: 0.8rem; align-items: start; font-size: 0.9rem; line-height: 1.45; color: var(--ink-dim); cursor: pointer; }
.check strong { color: var(--ink); }
.check input { width: 22px; height: 22px; margin: 0.1rem 0 0; accent-color: var(--leek-deep); }
.has-js .inname:not(.is-open) { display: none; }
.inname { overflow: hidden; }
.form-error { background: var(--ink); color: var(--white); border-radius: 12px; padding: 0.7rem 1rem; font-weight: 600; }
.form-error[hidden] { display: none; }
.form-scope { font-size: 0.85rem; color: var(--ink-dim); }

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

.footer {
    background: var(--ink); color: var(--oat);
    display: grid; grid-template-columns: 1.4fr repeat(3, minmax(0, 1fr)); gap: 2rem;
    padding: clamp(3rem, 6vw, 5rem) var(--gutter);
    font-size: 0.95rem;
}
.footer-word { font-size: 2.8rem; line-height: 0.82; text-transform: uppercase; margin-bottom: 0.8rem; }
.footer-label { font-size: 0.78rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 0.4rem; }

/* --------------------------------------------------------------------------
   THANK-YOU PAGE (thanks.html)
   -------------------------------------------------------------------------- */

.nav--thanks { position: absolute; }
.has-js .nav--thanks { position: absolute; }
.thanks {
    display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    min-height: min(100svh, 980px);
    background: var(--leek); color: var(--white);
}
.thanks-copy {
    display: grid; align-content: center; gap: 1.3rem; justify-items: start;
    padding: calc(var(--nav-h) + 3rem) clamp(1.5rem, 5vw, 5rem) 4rem var(--gutter);
}
.thanks-title { max-width: 9ch; }
.thanks-lede { font-size: clamp(1.1rem, 0.5vw + 1rem, 1.35rem); max-width: 32rem; }
.thanks-amount { white-space: nowrap; }
.thanks-giftaid { font-weight: 600; }
.thanks-giftaid[hidden], .thanks-crate[hidden], .thanks-gift[hidden] { display: none; }
.thanks-crate, .thanks-gift { display: grid; gap: 0.6rem; border-top: 2px solid rgba(var(--white-rgb), 0.5); padding-top: 1rem; width: 100%; max-width: 34rem; }
.thanks-gift-name { font-family: var(--font-display); font-weight: 900; font-size: 2.6rem; line-height: 0.9; }
.thanks-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; }
.thanks-fine { font-size: 0.92rem; }
.thanks-photo { position: relative; min-height: 520px; }
.thanks-photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 55% 40%; }
@media (max-width: 900px) {
    .thanks { grid-template-columns: 1fr; }
    .thanks-photo { min-height: 0; aspect-ratio: 4 / 3; order: -1; }
    .thanks-copy { padding-top: 2.5rem; }
    .thanks-page .nav { position: relative; background: var(--oat); }
}

/* --------------------------------------------------------------------------
   REVEAL GATES. Pre-hidden only under the synchronous .has-js class.
   -------------------------------------------------------------------------- */

.has-js [data-hero-fade],
.has-js [data-reveal] { opacity: 0; }
.has-js [data-reveal-group] > * { opacity: 0; }
.has-js [data-hero-line] { transform: translateY(105%); }
.has-js .raised-fill { transform: scaleX(0); }
.has-js .split-seg { transform: scaleX(0); }
.has-js [data-story] .story-photo img { clip-path: inset(100% 0 0 0); }

@media (prefers-reduced-motion: reduce) {
    .has-js [data-hero-fade],
    .has-js [data-reveal],
    .has-js [data-reveal-group] > * { opacity: 1; transform: none; }
    .has-js [data-hero-line] { transform: none; }
    .has-js .raised-fill { transform: scaleX(var(--raised)); }
    .has-js .split-seg { transform: none; }
    .has-js [data-story] .story-photo img { clip-path: none; }
}

/* --------------------------------------------------------------------------
   RESPONSIVE
   -------------------------------------------------------------------------- */

@media (max-width: 1100px) {
    .nav-links { display: none; }
    .nav-pills { margin-left: auto; }
    .hero-inner { grid-template-columns: minmax(0, 1fr) minmax(300px, 360px); }
}

@media (max-width: 900px) {
    :root { --nav-h: 64px; }
    .nav { gap: 1rem; }
    .nav-volunteer { display: none; }
    .wordmark-text { font-size: 1.35rem; }
    .wordmark-mark { width: 32px; height: 32px; }

    /* Phone hero: the photograph holds the headline, the card sits below
       it on solid leek-deep, then the raised strip. */
    .hero { min-height: 0; }
    .hero-photo { height: 82svh; bottom: auto; }
    .hero-scrim {
        bottom: auto; height: 82svh;
        background: linear-gradient(180deg, rgba(var(--ink-rgb), 0.5) 0, rgba(var(--scrim-rgb), 0.15) 26%, rgba(var(--scrim-rgb), 0.78) 60%, var(--leek-deep) 100%);
    }
    .hero-inner { grid-template-columns: 1fr; padding-top: calc(var(--nav-h) + 1rem); gap: 1.6rem; }
    .hero-copy { min-height: calc(82svh - var(--nav-h) - 2rem); display: flex; flex-direction: column; justify-content: flex-end; }
    .hero-photo { object-position: 64% 30%; }
    .raised { grid-template-columns: 1fr; gap: 0.6rem; }
    .raised-meta { white-space: normal; }

    .why { grid-template-columns: 1fr; }
    .why-photo { min-height: 0; aspect-ratio: 4 / 3.4; order: -1; }

    .crate-head { grid-template-columns: 1fr; }

    .numbers-list { grid-template-columns: 1fr; gap: 2.4rem; }

    .story, .story--flip { grid-template-columns: 1fr; min-height: 0; }
    .story-photo { aspect-ratio: 4 / 3.2; }
    .story--flip .story-photo { order: 0; }

    .trust { grid-template-columns: 1fr; }
    .help { grid-template-columns: 1fr; }
    .help-photo { min-height: 0; aspect-ratio: 3 / 2; }
    .give { grid-template-columns: 1fr; }
    .give-intro { position: static; }
    .footer { grid-template-columns: 1fr 1fr; }
    .footer-mark { grid-column: 1 / -1; }
}

/* Phone crate: the shelf is one column on the left and the crate stays
   pinned beside it, so packing is always a sideways drag and a vertical
   swipe scrolls the shelf. */
@media (max-width: 700px) {
    .crate-grid {
        grid-template-columns: minmax(0, 1.12fr) minmax(0, 1fr);
        grid-template-areas: "shelf panel";
        column-gap: 0.75rem;
    }
    .shelf-list { grid-template-columns: 1fr; --pic-h: 104px; row-gap: 0.9rem; }
    .item { gap: 0.3rem; }
    .item-name { margin-top: 0.7rem; min-height: 0; }
    .shelf-slot::before { left: calc(-1 * clamp(0.9rem, 2vw, 1.8rem)); right: calc(-1 * clamp(0.9rem, 2vw, 1.8rem)); }
    .crate-panel { top: calc(var(--nav-h) + 0.75rem); }
    .crate-list-wrap--panel { display: none; }
    .crate-list-wrap--below:not([hidden]) { display: block; }
    .crate-box { margin-top: 30%; }
    .crate-readout { gap: 0.6rem; margin-top: 1rem; }
    .crate-total { font-size: clamp(3.4rem, 15vw, 4.4rem); }
    .crate-outcome { font-size: 0.86rem; min-height: 0; }
    .crate-actions { flex-direction: column; align-items: stretch; }
    .crate-actions .btn { padding: 0.8rem 0.6rem; font-size: 0.92rem; white-space: normal; text-align: center; }
    .crate-more { top: 18%; font-size: 0.78rem; padding: 0.2rem 0.55rem; }
    .chips--form { grid-template-columns: 1fr 1fr; }
    .field-row { grid-template-columns: 1fr; }
    .shift { grid-template-columns: 1fr auto; }
    .shift-when { grid-column: 1 / -1; }
    .buyall { align-items: end; padding-bottom: 6.5rem; }
    .buyall-plate { max-width: none; }
    .footer { grid-template-columns: 1fr; }
}

A one-page appeal where visitors give by packing a crate. The worked example is Hollins Lane Larder, a community food bank in the old Co-op on Hollins Lane, Walkley, Sheffield, raising £38,000 to keep its shelves full until spring.

The first screen does what strong charity sites do: a full-bleed photograph, a headline as big as the window allows, a give card whose amounts say what they buy, and a raised-so-far strip along the bottom. Further down, nine items sit on a wooden shelf with their prices. Drag one into the crate, tap it or press Enter, and the total, the sentence describing what the crate does and the donation form amount all update together.

Static files, no build step, no backend.


Quick Start

  1. Upload the folder to any static host, or open index.html in a browser.
  2. Edit the appeal copy and figures in index.html: the headline, the raised-so-far strip, the stats and the stories are plain markup.
  3. Replace the shelf items with what your appeal buys (see Pack a crate).
  4. Replace the photographs in assets/img/ (see Images).
  5. Point the donation form at your donation platform (see The donation form).

Using It With Your Own Design

Every section is a self-contained block in index.html, and every initialiser in assets/script.js returns early when its markup is missing. Delete the stories, the trust card, the shifts or the Buy it all band and the rest keeps working.

Behaviour hooks are data- attributes (data-crate, data-item, data-give-form, data-give-card, data-amount-input, data-outcome and so on); class names are for styling only. To restyle, repaint the colour tokens at the top of assets/style.css, then adjust section rules. Type is Smooch Sans (display and figures, weights 800 and 900) and Be Vietnam Pro (body, 400, 600 and 700), self-hosted in assets/fonts/ (see Fonts).

Files

File What it is
index.html The appeal page
thanks.html The thank-you page the form submits to
assets/style.css Tokens, layout, section styles, reveal gates and reduced-motion overrides
assets/script.js Readable source
assets/script.min.js Minified copy of the same script; point the <script> tags at it in production if you like
assets/favicon.svg The crate mark
assets/fonts/ Self-hosted Smooch Sans and Be Vietnam Pro (Latin woff2) and their licence, OFL.txt
assets/img/ Bundled WebP images
assets/img-src/ Transparent masters for the crate and the nine item cut-outs
assets/img-manifest.json Sources, crops and bake settings for every image

Pack a crate

Each shelf item is one button:

<button class="item" type="button"
        data-item="nappies"
        data-price="8"
        data-extra="1|week of nappies|weeks of nappies"
        data-fill="1"
        data-name="Nappies, a week's pack">
    <span class="item-pic"><img src="assets/img/item-nappies.webp" width="420" height="544" alt="" draggable="false"></span>
    <span class="item-name">Nappies, a week's pack</span>
    <span class="item-price">£8</span>
    <span class="visually-hidden">Add to crate</span>
</button>
Attribute What it does
data-item A unique id
data-price Price in pounds; the crate total is the sum
data-name Used in the crate list, the form summary and screen reader announcements
data-meals For food: how many meals one unit adds to "about N meals"
data-extra For anything else: `amount per unit
data-fill How many of this item Fill it for me and Buy it all pack

The outcome sentence joins the meals and the phrases: "£45 packs about 34 meals, 240 cups of tea, a wash bag, a week of nappies and a Thursday supper for a family." Keep the data-fill counts adding up to the crate price you quote elsewhere on the page (£45 here, in the Buy it all band, the hero chip and the no-JavaScript line).

Reskinning the shelf. For a wildlife trust: nest boxes, metres of hedging, a bat detector night. For a hospice: comfort kits, night-nurse hours, a family room night. For a music charity: a violin loan, a term of lessons, a rehearsal room. Use data-extra for anything that isn't a meal and leave data-meals off.

Where packed items sit. The crate is one photograph drawn twice; the front copy is clipped from --front-top (on .crate-box) down, so items sit behind the front wall. If you replace crate.webp, set --front-top to where the front wall starts, as a percentage of the image height. The twelve drawn positions are the SLOTS array in script.js (left, bottom, rotation, stacking); a thirteenth item and beyond still count and show as "+n more".

Input. Every item is a native button, so click, tap, Enter and Space all add it. Dragging is an extra route: a press commits nothing, the drag starts only when a finger moves more sideways than up or down, and a vertical swipe over the shelf scrolls the page. On phones the shelf is one column with the crate pinned beside it, so packing is always a sideways move. The crate list has a remove and an add button per line; Empty the crate clears it.

One amount everywhere. The crate total writes the amount into the hero card, the nav Donate pill, the form, the button label and the Gift Aid line. Emptying the crate hands the form back to the £45 preset.

The give card and the donation form

The hero card is a small form with Once / Monthly, four amount chips and an amount field. Each chip's data-outcome is the sentence shown when that amount is chosen. Other amounts get a generic line (about two meals per pound, or whole family crates from £45), written in outcomeFor() in script.js; change the rate there to suit your appeal.

The full form at #give collects frequency, amount, name, email, a Gift Aid declaration and optional gift-in-someone's-name details, plus a hidden crate field listing what was packed. It takes no payment. It submits by GET to thanks.html. To go live, either change the form's action to your platform's checkout, or post the fields to your own handler and redirect to thanks.html with the same query parameters. JustGiving, Enthuse and Stripe Checkout can all return to a thank-you URL.

The thank-you page

thanks.html reads amount, frequency, crate, giftaid, in_name, gift_name and name from the query string and shows them back: the amount, the Gift Aid value, the packed items and who the card goes to. Without them it reads as a plain thank-you. Share the appeal uses the Web Share sheet where available and copies the link otherwise; it shares index.html, set by data-share-url.

Editing the rest

  • Raised so far. The figure is final in the markup; data-count-to is what the count-up animates to, so keep the two in agreement. The bar's share is --raised: 0.61 on .raised-fill. Update both, and the days left, by hand.
  • Stats. Each <dt> is the final value with a matching data-count-to.
  • Where every pound goes. Segment widths are --share values on .split-seg; keep them in step with the legend.
  • Shifts. Each row is a list item with a day, a time, a description and a sign-up link. A full shift uses the pill--full span instead of a link.

Palette

Token Value Role
--leek #13853A Main saturated ground: the raised strip, Pack a crate, the green story, Give. White text only (4.73:1)
--leek-deep #0E6B2E The shelf, big figures on oat (6.02:1)
--oat #FBF3E2 Paper ground
--lilac #D8CBFF Colour-block bands and the Volunteer pill
--ink #13221A Text, shop-sign plate, footer
--yolk #FFCC1A The ask only: Donate, prices, selected amount, Give buttons. Never a ground
--tomato #E5452B The marquee strip and the split bar; never body text

The hero scrim uses its own fixed --scrim-rgb so a repaint never leaves the headline unreadable over the photograph. Measured ratios and limits are in the comment at the top of style.css.

Images

All images are bundled WebP derivatives; nothing is hot-linked. Regenerate them with:

bun scripts/build-template-assets.ts charity-campaign-template --force

Photographs (Pexels licence; free to use and modify, not to be sold unaltered):

File Source
hero-sorting-room.webp, hero-sorting-room-phone.webp https://www.pexels.com/photo/6994869/
why-long-table.webp https://www.pexels.com/photo/6994963/
supply-tins.webp https://www.pexels.com/photo/6994944/
story-samira.webp https://www.pexels.com/photo/6994787/
story-graham.webp https://www.pexels.com/photo/37894473/
story-maureen.webp https://www.pexels.com/photo/6647066/
help-delivery.webp https://www.pexels.com/photo/7551581/
thanks-unpacking.webp https://www.pexels.com/photo/6721918/

hero-sorting-room-phone.webp is a 1200px-wide crop of the hero that phones in portrait load instead (the <picture> and the two preloads in the <head> of index.html). Its manifest crop starts at 64% of the width the phone never sees, so with the hero's object-position: 64% 30% it shows exactly the same window as the full frame. If you replace the hero, rebake both, or delete the <source> and the phone preload and drop the media attribute from the other preload.

The names and quotes attached to the photographs are fiction written for the demo; replace them with your own people, with their permission.

Crate and item cut-outs (crate.webp, item-*.webp) are AI-generated images made for this template, with plain packaging and no brands. Their transparent masters are in assets/img-src/, and the manifest crops each one to its object. To use your own, save a transparent WebP or PNG in assets/img-src/ under the same name, rebuild, and update the width/height attributes in index.html.

Fonts

Smooch Sans and Be Vietnam Pro are self-hosted in assets/fonts/, so the page makes no render-blocking third-party request. The files are the Latin subsets Google Fonts serves: one variable Smooch Sans file covers 800 and 900, and Be Vietnam Pro has a file per weight. They are declared by the @font-face rules at the top of assets/style.css with the weights and unicode-range Google uses, and the two faces on the first screen (Smooch Sans and Be Vietnam Pro 400) are preloaded in the <head>.

Licence: SIL Open Font License 1.1, full texts in assets/fonts/OFL.txt. Smooch Sans: Copyright 2016 The Smooch Sans Project Authors (https://github.com/googlefonts/smooch-sans). Be Vietnam Pro: Copyright 2021 The Be Vietnam Pro Project Authors (https://github.com/bettergui/BeVietnamPro).

To swap them, put your .woff2 files in assets/fonts/, point the @font-face rules and --font-display / --font-body at them, and change the preload hrefs in both pages (or drop the preloads). To go back to Google Fonts instead, delete the @font-face rules and the preloads and add the Google Fonts <link> to each <head>.

Accessibility

  • Every shelf item is a native button with a label that includes its price and how many are already in the crate; adding or removing announces the new total through a status region.
  • The crate list's remove and add buttons are labelled per item, and focus stays on the list after a removal.
  • Amount chips and Once / Monthly are native radio groups; the form marks invalid fields and names them in one message.
  • Without JavaScript, or with the CDN blocked, the shelf reads as a priced list, every figure is final and the form submits.
  • Under reduced motion the crate works the same; flights, count-ups, reveals and the marquee are dropped.

Dependencies

  • GSAP 3.15.0 core and ScrollTrigger (jsDelivr). Works with GSAP 3.12+.
  • Lenis 1.3.17 (unpkg), optional. Remove its <script> tag, add data-smooth="off" to <html>, or add ?smooth=off to the URL.
  • Smooch Sans and Be Vietnam Pro, self-hosted in assets/fonts/.

Teardown

Everything the script creates lives in one GSAP context exposed as window.gsapContext. window.gsapContext.revert() kills its tweens and ScrollTriggers, removes its listeners, the drawn crate slots and any drag ghost, and stops Lenis, leaving other animations on the page alone.