Back to Templates 021

Charity Campaign Template Template

FREE

A free one-page river-restoration appeal built around a draggable before/after comparator: a gauge-board divider wipes between the degraded and the restored river, and every figure on the page counts like a reading.

ScrollTriggerDraggable draggableclip-pathbefore-aftercount-upscroll-revealstaggersvg-line-drawprogress-bar Lenis intermediate

About This Template

A complete one-page campaign site for a charity appeal, written as a river-restoration trust: a placard hero with a live raised-so-far counter, the problem in measured figures, donation tiers priced in metres of bank and tonnes of gravel, work party dates, and a donation form that words out exactly what an amount buys.

The signature is a before/after comparator. Photographs of a degraded and a restored river sit stacked in one full-bleed frame, and a draggable divider styled as a river gauge board wipes between them. It is keyboard operable as a real slider, glides with InertiaPlugin if you add it, and on touch it commits nothing until a gesture is clearly horizontal, so swiping vertically over the photographs still scrolls the page.

The art direction refuses both charity defaults: no NGO blue with smiling stock photos, and no guilt-dark ground with a red donate button. The ground is a saturated viridian, chalk carries the type, one rationed water cyan marks the readings, and the donate buttons are chalk with river ink. Barlow Condensed sets the placard caps, Source Serif 4 the prose, and B612 Mono every figure.

Photography ships as bundled WebP derivatives with a cool viridian grade baked into the pixels, regenerated from a one-file manifest, and the whole site is static files with no build step.

What's Included

  • Complete deployable one-page appeal: nav, placard hero with live counter, measured-problem stats band, the before/after comparator, donation tiers, work party dates, donation form, footer
  • Signature before/after comparator: two photographs in one frame, clipped by a draggable divider styled as a river gauge board with depth markings
  • The comparator reads coordinates from the raw event null-safe, commits nothing on a touch press, and only scrubs once a gesture is clearly horizontal, so a vertical swipe on the board scrolls the page instead of yanking the divider
  • Keyboard operable as a real slider: arrow keys, Page Up and Down, Home and End, with aria-valuetext describing how much of the restored river is shown
  • Optional inertia: add the InertiaPlugin script tag and the board glides to a stop; without it the board simply stays put
  • Raised-so-far counter with an animated progress bar, and GSAP count-ups on every campaign figure, all set in B612 Mono like instrument readings
  • Donation form with amount presets, a live outcome line that words out what any amount buys, a once or monthly toggle, and a Gift Aid declaration
  • Tier cards priced in concrete outcomes - trout fry, metres of bank, tonnes of gravel - whose buttons preselect the amount on the way down to the form
  • Photography bundled as baked WebP derivatives with a cool viridian grade, under 800KB total, regenerated from a one-file manifest
  • Degrades completely: with JavaScript off the comparator becomes a labelled stacked pair and every figure is already in the markup at its final value
  • Reduced motion keeps the comparator fully draggable - it is a control, not decoration - and drops only the count-ups, reveals and intro sweep
  • Three-hue token block with RGB triples: repaint river, chalk and water and the whole site reskins

Perfect For

  • Charity appeals and fundraising campaign one-pagers
  • Rivers trusts, wildlife trusts and conservation groups showing restoration results
  • Community campaigns with a concrete target and a raised-so-far figure
  • Any organisation whose strongest argument is a before and after photograph
  • Volunteer recruitment pages with dated work parties or events

How It Works

Structure

One script initialises each section inside a single gsap.context, with gsap.matchMedia keyed only on reduced motion so exactly one branch runs on every device. Every querySelector is guarded, so any section of the page can be deleted and the rest keeps working.

The restoration comparator

Both photographs fill the same frame and the 'before' pane is clipped with a CSS inset that follows one number: the divider position as a percentage. A GSAP Draggable rides a detached proxy element purely for its gesture handling, and the position is computed from the raw event's coordinates, null-safe, because the cached pointer reads 0 when a browser reclaims a touch gesture for scrolling. A touch press commits nothing; the drag only starts scrubbing once the gesture is more horizontal than vertical, and allowNativeTouchScrolling leaves vertical swipes to the page.

The gauge board

The divider handle is styled as a river depth gauge: a chalk board with tick bands drawn by a repeating gradient, mono depth numbers, and a round grip. It carries role=slider with aria-valuenow and a valuetext that reports how much of the restored river is shown, and its keydown handler implements the full slider keyboard contract, because a Draggable is invisible to a keyboard.

Counters and reveals

Every campaign figure sits in the markup at its final value; the count-up tweens a proxy from zero and writes formatted text through a thousands separator, triggered once per element by ScrollTrigger. The reveals are a single fromTo per element gated behind an html.has-js class that is only added once GSAP is confirmed present, so a blocked CDN leaves a readable page rather than a blank one.

Without JavaScript

The comparator renders as a stacked pair of labelled figures, the drag instruction swaps for a caption describing the pair, the counter and stats are already at their final values, and the donation form still lays out and labels correctly. Under reduced motion the comparator stays fully interactive and only the movement around it is dropped.

Plugins ScrollTrigger, Draggable
Difficulty Intermediate
Smooth Scroll Lenis Integration
Includes HTML + JS + CSS source, documentation, lifetime updates
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="robots" content="noindex, nofollow">
    <meta name="description" content="Brenna Rivers Trust - the Welsden Reach appeal. £60,000 restores 3.1 km of the River Brenna: fenced banks, raked gravel, trees, and the trout back. See exactly what your money does.">
    <title>Brenna Rivers Trust - Charity Campaign Template | GSAP Vault</title>
    <link rel="stylesheet" href="assets/style.css">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&family=B612+Mono:wght@400;700&display=swap" rel="stylesheet">
</head>
<body>

<a class="skip" href="#give">Skip to the donation form</a>

<nav class="nav" aria-label="Main">
    <a class="nav-mark" href="#top">
        <span class="nav-mark-rule" aria-hidden="true"></span>
        <span class="nav-mark-text">Brenna<br>Rivers Trust</span>
    </a>
    <ul class="nav-links">
        <li><a href="#problem">The problem</a></li>
        <li><a href="#work">The work</a></li>
        <li><a href="#money">What it costs</a></li>
        <li><a href="#parties">Work parties</a></li>
    </ul>
    <a class="btn btn--chalk nav-cta" href="#give">Donate</a>
</nav>

<!-- ============ HERO: the placard ============ -->
<header class="hero" id="top">
    <div class="hero-inner">
        <div class="hero-placard">
            <p class="eyebrow" data-fade>Welsden Reach Appeal <span aria-hidden="true">&middot;</span> Registered charity no. 1198472</p>
            <h1 class="placard">
                <span class="mask"><span class="pl">A river is not</span></span>
                <span class="mask"><span class="pl">supposed</span></span>
                <span class="mask"><span class="pl">to be brown.</span></span>
            </h1>
            <svg class="hero-thread" viewBox="0 0 560 46" fill="none" aria-hidden="true" preserveAspectRatio="xMinYMid meet">
                <path data-thread d="M2 30 C 50 8, 92 44, 148 30 S 240 6, 300 24 S 402 44, 452 22 S 530 10, 558 26"
                      stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
            </svg>
        </div>

        <div class="hero-side">
            <p class="standfirst" data-fade>The Brenna ran clear until the weir pool silted and two banks
            went in 2019. Putting a river right is not complicated: fence the banks, plant
            them, rake the gravel, count what comes back. It is just slow, and it is done
            in waders, not in meetings.</p>

            <div class="counter" data-counter>
                <p class="counter-raised">
                    <span class="counter-fig mono"><span aria-hidden="true">£</span><span class="sr-only">£</span><span data-count data-count-to="41380">41<span class="fig-sep">,</span>380</span></span>
                    <span class="counter-of">raised of £60,000</span>
                </p>
                <div class="counter-bar" role="img" aria-label="69 per cent of the £60,000 target raised">
                    <div class="counter-fill" data-counter-fill style="width: 69%"></div>
                </div>
                <p class="counter-meta mono"><span data-count data-count-to="689">689</span> donors &middot; appeal closes 31 March 2027</p>
            </div>

            <div class="hero-ctas" data-fade>
                <a class="btn btn--chalk" href="#give">Give to the appeal</a>
                <a class="btn btn--ghost" href="#money">See where it goes</a>
            </div>
        </div>
    </div>
</header>

<!-- ============ THE PROBLEM: gauge figures ============ -->
<section class="problem" id="problem" aria-labelledby="problem-title">
    <div class="section-inner">
        <header class="section-head" data-fade>
            <p class="eyebrow">The problem</p>
            <h2 class="section-title" id="problem-title">What is wrong, measured</h2>
            <p class="section-lede">We do not do vague. These are the numbers from the reach
            between Welsden Bridge and Millers Meadow, and they are the numbers this appeal
            exists to change.</p>
        </header>

        <dl class="stats">
            <div class="stat" data-fade>
                <dt class="stat-fig mono"><span data-count data-count-to="3.1" data-count-decimals="1">3<span class="fig-sep">.</span>1</span><span class="stat-unit">km</span></dt>
                <dd class="stat-caption">of the Welsden reach rated <em>Poor</em> by the Environment
                Agency. It is the only stretch of the Brenna below <em>Moderate</em>.</dd>
            </div>
            <div class="stat" data-fade>
                <dt class="stat-fig mono"><span data-count data-count-to="212">212</span><span class="stat-unit">hrs</span></dt>
                <dd class="stat-caption">of storm overflow into the river above the bridge in 2025,
                logged by our own volunteers' monitors, not estimated.</dd>
            </div>
            <div class="stat" data-fade>
                <dt class="stat-fig mono">0<span class="stat-unit">redds</span></dt>
                <dd class="stat-caption">trout spawning nests counted below the weir last winter.
                In 1998 the same walk counted forty.</dd>
            </div>
            <div class="stat" data-fade>
                <dt class="stat-fig mono"><span data-count data-count-to="38">38</span><span class="stat-unit">tonnes</span></dt>
                <dd class="stat-caption">of silt a year off two collapsed banks, smothering the
                gravel that trout and lamprey spawn in.</dd>
            </div>
        </dl>
    </div>
</section>

<!-- ============ THE WORK: before/after comparator ============ -->
<section class="work" id="work" aria-labelledby="work-title">
    <div class="section-inner work-head" data-fade>
        <p class="eyebrow">The work</p>
        <h2 class="section-title" id="work-title">The same water, four years on</h2>
        <p class="section-lede">Millers Meadow was fenced, planted and re-graded between 2021
        and 2025, at £38 a metre. Drag the gauge board across and see what that bought.
        The unrestored reach below Fentley outfall still looks like the left-hand side.</p>
    </div>

    <div class="comp" data-comparator data-comp-start="46">
        <figure class="comp-pane comp-pane--after">
            <img src="assets/img/comp-after.webp" alt="The restored reach at Millers Meadow: a clear stream winding between planted, grassy banks" width="1600" height="900" loading="lazy">
            <figcaption class="comp-tag comp-tag--after"><span class="mono">2025</span> Millers Meadow, restored</figcaption>
        </figure>
        <figure class="comp-pane comp-pane--before">
            <img src="assets/img/comp-before.webp" alt="The unrestored reach below Fentley outfall: silt-brown water against a collapsed, bare bank" width="1600" height="900" loading="lazy">
            <figcaption class="comp-tag comp-tag--before"><span class="mono">2021</span> Below Fentley outfall</figcaption>
        </figure>
        <div class="comp-staff" data-comp-handle role="slider" tabindex="0"
             aria-orientation="horizontal" aria-valuemin="0" aria-valuemax="100" aria-valuenow="46"
             aria-label="Before and after comparison divider">
            <div class="staff-board" aria-hidden="true">
                <span class="staff-num staff-num--1">1.0</span>
                <span class="staff-num staff-num--2">0.8</span>
                <span class="staff-num staff-num--3">0.6</span>
                <span class="staff-num staff-num--4">0.4</span>
                <span class="staff-num staff-num--5">0.2</span>
            </div>
            <div class="staff-grip" aria-hidden="true">
                <svg viewBox="0 0 24 24" width="22" height="22" fill="none">
                    <path d="M8 7 L3 12 L8 17 M16 7 L21 12 L16 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
                </svg>
            </div>
        </div>
    </div>
    <p class="comp-help section-inner" data-fade><span class="js-only">The board is a river gauge from
    the reach it measures. Drag it, or focus it and use the arrow keys.</span><span
    class="nojs-only">Two photographs of the reach: restored above, unrestored below.</span></p>
</section>

<!-- ============ WHAT YOUR MONEY DOES ============ -->
<section class="money" id="money" aria-labelledby="money-title">
    <div class="section-inner">
        <header class="section-head" data-fade>
            <p class="eyebrow">What it costs</p>
            <h2 class="section-title" id="money-title">What your money does. Exactly.</h2>
            <p class="section-lede">Restoration is bought by the metre, the tonne and the tree,
            so that is how we ask for it. Last year 92p in every pound went on the river itself;
            the accounts are on the Charity Commission register.</p>
        </header>

        <div class="money-grid">
            <div class="tiers">
                <article class="tier" data-fade>
                    <p class="tier-price mono">£10</p>
                    <p class="tier-what">Rears and releases <strong>25 brown trout fry</strong> below
                    Welsden weir, from Brenna broodstock.</p>
                    <a class="btn btn--chalk btn--small" href="#give" data-give="10">Give £10</a>
                </article>
                <article class="tier" data-fade>
                    <p class="tier-price mono">£25</p>
                    <p class="tier-what">Fences and plants <strong>four metres of bare bank</strong>:
                    willow whips, stakes, and a year of checks.</p>
                    <a class="btn btn--chalk btn--small" href="#give" data-give="25">Give £25</a>
                </article>
                <article class="tier" data-fade>
                    <p class="tier-price mono">£60</p>
                    <p class="tier-what">Buys <strong>a tonne of washed gravel</strong>, raked into
                    a spawning riffle by the work parties.</p>
                    <a class="btn btn--chalk btn--small" href="#give" data-give="60">Give £60</a>
                </article>
                <article class="tier" data-fade>
                    <p class="tier-price mono">£150</p>
                    <p class="tier-what">Kits out <strong>one work party for a season</strong>:
                    waders, gloves, loppers and the first aid box.</p>
                    <a class="btn btn--chalk btn--small" href="#give" data-give="150">Give £150</a>
                </article>
            </div>
            <figure class="money-photo" data-fade>
                <img src="assets/img/planting.webp" alt="Gloved hands setting a rooted whip into riverbank soil" width="760" height="950" loading="lazy">
                <figcaption>Bare-root planting on the Fentley bank. Every whip is £1.20 and most of them take.</figcaption>
            </figure>
        </div>
    </div>
</section>

<!-- ============ WORK PARTIES ============ -->
<section class="parties" id="parties" aria-labelledby="parties-title">
    <div class="section-inner">
        <div class="parties-grid">
            <div class="parties-copy">
                <header class="section-head" data-fade>
                    <p class="eyebrow">Work parties</p>
                    <h2 class="section-title" id="parties-title">Bring boots</h2>
                    <p class="section-lede">Second Saturday of the month, 9.30 to 1.00. No experience
                    needed and no standing around being thanked. Tools, waders and tea provided;
                    bring boots that can get wet and a coat that can get worse.</p>
                </header>

                <ol class="party-list">
                    <li class="party" data-fade>
                        <p class="party-date mono">Sat 12 Sep</p>
                        <div class="party-body">
                            <p class="party-task">Willow spiling at Millers Meadow</p>
                            <p class="party-meta mono">6 places</p>
                        </div>
                        <a class="btn btn--ghost btn--small" href="mailto:parties@brennariverstrust.org.uk?subject=Work%20party%2012%20September">Take a place</a>
                    </li>
                    <li class="party" data-fade>
                        <p class="party-date mono">Sat 10 Oct</p>
                        <div class="party-body">
                            <p class="party-task">Gravel raking below the weir</p>
                            <p class="party-meta mono">9 places</p>
                        </div>
                        <a class="btn btn--ghost btn--small" href="mailto:parties@brennariverstrust.org.uk?subject=Work%20party%2010%20October">Take a place</a>
                    </li>
                    <li class="party" data-fade>
                        <p class="party-date mono">Sat 14 Nov</p>
                        <div class="party-body">
                            <p class="party-task">Bare-root planting, Fentley bank</p>
                            <p class="party-meta mono">12 places</p>
                        </div>
                        <a class="btn btn--ghost btn--small" href="mailto:parties@brennariverstrust.org.uk?subject=Work%20party%2014%20November">Take a place</a>
                    </li>
                    <li class="party party--full" data-fade>
                        <p class="party-date mono">Sat 12 Dec</p>
                        <div class="party-body">
                            <p class="party-task">Winter bird and outfall survey</p>
                            <p class="party-meta mono">Full</p>
                        </div>
                        <span class="party-full-note mono" aria-hidden="true">Full</span>
                    </li>
                </ol>
            </div>
            <figure class="parties-photo" data-fade>
                <img src="assets/img/work-party.webp" alt="Volunteers with sacks clearing litter from a riverbank" width="1100" height="760" loading="lazy">
                <figcaption>The October party, clearing the town reach. Forty sacks before lunch.</figcaption>
            </figure>
        </div>
    </div>
</section>

<!-- ============ DONATE ============ -->
<section class="give" id="give" aria-labelledby="give-title">
    <div class="section-inner give-grid">
        <div class="give-intro" data-fade>
            <p class="eyebrow">Donate</p>
            <h2 class="section-title" id="give-title">Give to the appeal</h2>
            <p class="section-lede">Every gift is spent on the reach this page describes.
            If the appeal passes its target, the surplus starts the next reach upstream;
            it does not disappear into admin.</p>
            <figure class="give-photo">
                <img src="assets/img/riffle.webp" alt="Clean gravel under shallow, clear water" width="1000" height="660" loading="lazy">
                <figcaption>A raked riffle after one winter. The gravel stays clean when the banks hold.</figcaption>
            </figure>
        </div>

        <form class="give-form" data-give-form novalidate>
            <fieldset class="gf-amounts">
                <legend class="gf-label mono">Amount</legend>
                <div class="gf-amount-row" role="group">
                    <label class="gf-pill"><input type="radio" name="amount" value="10"><span class="mono">£10</span></label>
                    <label class="gf-pill"><input type="radio" name="amount" value="25" checked><span class="mono">£25</span></label>
                    <label class="gf-pill"><input type="radio" name="amount" value="60"><span class="mono">£60</span></label>
                    <label class="gf-pill gf-pill--other">
                        <input type="radio" name="amount" value="other">
                        <span class="mono">Other</span>
                    </label>
                </div>
                <div class="gf-other" data-gf-other hidden>
                    <label class="gf-label mono" for="gf-other-amount">Your amount, £</label>
                    <input class="gf-input mono" id="gf-other-amount" name="other-amount" type="number" min="1" step="1" inputmode="numeric" placeholder="40">
                </div>
                <p class="gf-outcome" data-gf-outcome aria-live="polite">£25 fences and plants four metres of bank.</p>
            </fieldset>

            <fieldset class="gf-freq">
                <legend class="gf-label mono">Frequency</legend>
                <div class="gf-amount-row" role="group">
                    <label class="gf-pill"><input type="radio" name="freq" value="once" checked><span>Once</span></label>
                    <label class="gf-pill"><input type="radio" name="freq" value="monthly"><span>Monthly</span></label>
                </div>
            </fieldset>

            <div class="gf-field">
                <label class="gf-label mono" for="gf-name">Name</label>
                <input class="gf-input" id="gf-name" name="name" type="text" autocomplete="name">
            </div>
            <div class="gf-field">
                <label class="gf-label mono" for="gf-email">Email</label>
                <input class="gf-input" id="gf-email" name="email" type="email" autocomplete="email">
            </div>

            <label class="gf-giftaid">
                <input type="checkbox" name="giftaid">
                <span>I am a UK taxpayer. Add Gift Aid to this gift: an extra 25p in
                the pound from HMRC, at no cost to me.</span>
            </label>

            <button class="btn btn--river gf-submit" type="submit" data-gf-submit>Give £25 once</button>
            <p class="gf-note">You will get one email receipt and a report when the reach is
            done. No newsletters unless you ask.</p>

            <div class="gf-done" data-gf-done hidden aria-live="polite"></div>
        </form>
    </div>
</section>

<!-- ============ FOOTER ============ -->
<footer class="footer">
    <div class="section-inner footer-grid">
        <div class="footer-col footer-col--mark">
            <p class="footer-mark">Brenna<br>Rivers Trust</p>
            <p class="footer-small">Restoring the River Brenna, from Harcup Edge to the Ottermere.</p>
        </div>
        <div class="footer-col">
            <p class="footer-head mono">Find us</p>
            <p class="footer-small">The Wharf House, Bridge Street,<br>Welsden &middot; Saturdays, or by the river</p>
        </div>
        <div class="footer-col">
            <p class="footer-head mono">Write to us</p>
            <p class="footer-small"><a href="mailto:hello@brennariverstrust.org.uk">hello@brennariverstrust.org.uk</a><br>
            <a href="mailto:parties@brennariverstrust.org.uk">parties@brennariverstrust.org.uk</a></p>
        </div>
        <div class="footer-col">
            <p class="footer-head mono">Small print</p>
            <p class="footer-small">Registered charity in England and Wales, no. 1198472.
            Accounts filed annually with the Charity Commission.</p>
        </div>
    </div>
</footer>

<!-- GSAP core and plugins -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/Draggable.min.js"></script>

<!-- Lenis smooth scroll (optional - the page works without it) -->
<script src="https://unpkg.com/lenis@1.3.17/dist/lenis.min.js"></script>

<script src="assets/script.js"></script>
</body>
</html>
/* ============================================
   BRENNA RIVERS TRUST - CHARITY CAMPAIGN TEMPLATE

   One script, one gsap.context. Every section initialises
   independently and every querySelector is guarded, so you
   can delete any section of index.html and the rest keeps
   working.

   Signature: the before/after restoration comparator. Two
   photographs of the same kind of river sit stacked in one
   frame; a draggable gauge board clips the "before" pane so
   you wipe between the degraded and the restored reach.
   It is driven from raw pointer coordinates (never GSAP's
   cached pointer, which reads 0 when a browser reclaims a
   touch gesture for scrolling), commits nothing on a touch
   press, and only scrubs once a touch gesture is clearly
   horizontal - so a vertical swipe on the board scrolls the
   page instead of yanking the divider.

   THE NUMBERS A BUYER EDITS are all in index.html: the
   appeal figures, the stats, the tier prices and the work
   party dates are plain markup. The only numeric config
   here is TIER_MATH below, which turns a donation amount
   into a concrete outcome line on the form.
   ============================================ */

/* What a pound buys, for the live outcome line under the amount
   picker. Each band: the minimum amount it applies from, the unit
   cost, and a function that words the outcome. Edit the costs and
   wording to match your own charity's arithmetic. */
var TIER_MATH = [
    {
        from: 60,
        per: 60,
        text: function (n) {
            return n === 1
                ? 'buys a tonne of washed gravel for a spawning riffle.'
                : 'buys ' + n + ' tonnes of washed gravel for spawning riffles.';
        }
    },
    {
        from: 25,
        per: 6.25,
        text: function (n) {
            return 'fences and plants ' + n + ' metres of bank.';
        }
    },
    {
        from: 1,
        per: 0.4,
        text: function (n) {
            return 'rears and releases ' + n + ' trout fry below the weir.';
        }
    }
];

if (typeof gsap !== 'undefined') {
    if (typeof ScrollTrigger !== 'undefined') gsap.registerPlugin(ScrollTrigger);
    if (typeof Draggable !== 'undefined') gsap.registerPlugin(Draggable);
    if (typeof InertiaPlugin !== 'undefined') gsap.registerPlugin(InertiaPlugin);
    /* Reveal targets are only pre-hidden once GSAP is known to be
       here, so a blocked CDN leaves a readable page, not a blank one. */
    document.documentElement.classList.add('has-js');
}

/* Runs the init straight away if the DOM is already parsed (a script
   executed late or deferred), and waits for DOMContentLoaded otherwise. */
(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})(function initBrenna() {
    if (typeof gsap === 'undefined') return;

    /* Lenis and ScrollTrigger must share one clock: Lenis is driven
       from gsap's ticker and pushes every scroll into ScrollTrigger,
       otherwise scrubbed and triggered animations read a scroll
       position a frame behind the one on screen. */
    var lenis = null;
    var lenisTick = null;
    if (typeof Lenis !== 'undefined') {
        lenis = new Lenis({ autoRaf: false });
        if (typeof ScrollTrigger !== 'undefined') {
            lenis.on('scroll', ScrollTrigger.update);
        }
        lenisTick = function (time) { lenis.raf(time * 1000); };
        gsap.ticker.add(lenisTick);
        gsap.ticker.lagSmoothing(0);
    }

    /* Handlers live in Maps keyed by element so cleanup can remove
       exactly what was added. */
    var clickHandlers = new Map();
    var keyHandlers = new Map();
    var inputHandlers = new Map();
    var submitHandlers = new Map();

    function on(el, type, fn, map) {
        if (!el) return;
        el.addEventListener(type, fn);
        var list = map.get(el) || [];
        list.push({ type: type, fn: fn });
        map.set(el, list);
    }

    function offAll(map) {
        map.forEach(function (list, el) {
            list.forEach(function (h) { el.removeEventListener(h.type, h.fn); });
        });
        map.clear();
    }

    /* Thousands separator for the count-ups: 41380 -> "41,380". */
    function fmt(value, decimals) {
        var fixed = value.toFixed(decimals);
        var parts = fixed.split('.');
        parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
        return parts.join('.');
    }

    /* Display-scale variant. B612 Mono gives , and . a full character cell,
       which reads as a stray space at headline size ("£41, 380"), so the
       separators are wrapped and the CSS pulls the next digit back in. */
    function fmtDisplay(value, decimals) {
        return fmt(value, decimals).replace(/[,.]/g, '<span class="fig-sep">$&</span>');
    }

    var comparator = null;

    var ctx = gsap.context(function () {

        /* ==================================================
           THE COMPARATOR
           A control, not decoration: it initialises whatever
           the motion preference is. Only its intro sweep is
           gated behind reduced motion, below.
           ================================================== */
        comparator = (function initComparator() {
            var comp = document.querySelector('[data-comparator]');
            if (!comp) return null;
            var handle = comp.querySelector('[data-comp-handle]');
            var before = comp.querySelector('.comp-pane--before');
            if (!handle || !before) return null;

            comp.classList.add('comp--ready');

            var MIN = 0;
            var MAX = 100;
            var start = parseFloat(comp.getAttribute('data-comp-start'));
            if (!isFinite(start)) start = 46;
            start = Math.max(MIN, Math.min(MAX, start));

            /* One number is the whole state: the divider position as a
               percentage from the left. Everything renders from it. */
            var state = { p: start };

            function render() {
                var p = state.p;
                before.style.clipPath = 'inset(0 ' + (100 - p) + '% 0 0)';
                handle.style.left = p + '%';
                handle.setAttribute('aria-valuenow', String(Math.round(p)));
                handle.setAttribute('aria-valuetext',
                    Math.round(100 - p) + '% of the restored river shown');
            }

            function setP(value, animate) {
                value = Math.max(MIN, Math.min(MAX, value));
                gsap.killTweensOf(state);
                if (animate) {
                    gsap.to(state, { p: value, duration: 0.4, ease: 'power2.out', onUpdate: render });
                } else {
                    state.p = value;
                    render();
                }
            }

            function pFromClientX(x) {
                var rect = comp.getBoundingClientRect();
                if (!rect.width) return state.p;
                return ((x - rect.left) / rect.width) * 100;
            }

            /* Coordinates come from the raw event, null-safe. When the
               browser reclaims a touch gesture for scrolling, Draggable's
               cached pointer reads 0 - mapping that to the track would
               slam the divider to the far left mid-scroll. */
            function pointFrom(event) {
                if (!event) return null;
                if (typeof event.clientX === 'number') {
                    return { x: event.clientX, y: event.clientY };
                }
                var t = (event.touches && event.touches[0]) ||
                        (event.changedTouches && event.changedTouches[0]);
                if (t && typeof t.clientX === 'number') {
                    return { x: t.clientX, y: t.clientY };
                }
                return null;
            }

            var drag = null;
            var gesture = { active: false, locked: false, touch: false, sx: 0, sy: 0, off: 0 };
            var hasInertia = typeof InertiaPlugin !== 'undefined';
            if (hasInertia) InertiaPlugin.track(state, 'p');

            if (typeof Draggable !== 'undefined') {
                /* The Draggable rides a detached proxy: the divider is
                   positioned in percent and the pane clipped to match, so
                   nothing wants Draggable's own transform. The proxy just
                   gives us its gesture handling. */
                var proxy = document.createElement('div');

                drag = Draggable.create(proxy, {
                    trigger: comp,
                    type: 'x',
                    /* Leaves vertical touch scrolling to the browser, so a
                       finger on the frame can still flick the page. */
                    allowNativeTouchScrolling: true,
                    onPress: function (event) {
                        var pt = pointFrom(event);
                        gesture.active = !!pt;
                        gesture.locked = false;
                        gesture.touch = !!event && (event.pointerType === 'touch' ||
                            (typeof event.type === 'string' && event.type.indexOf('touch') === 0));
                        gesture.off = 0;
                        if (!pt) return;
                        gesture.sx = pt.x;
                        gesture.sy = pt.y;

                        var onHandle = event.target && handle.contains(event.target);
                        if (onHandle) {
                            /* Grabbing the board should not nudge it: keep the
                               offset between pointer and divider for the drag. */
                            gesture.off = pFromClientX(pt.x) - state.p;
                        }
                        /* A mouse or pen press commits immediately (click the
                           frame, the divider goes there). A touch press commits
                           NOTHING: this might be the start of a page scroll. */
                        if (!gesture.touch) {
                            if (!onHandle) setP(pFromClientX(pt.x), true);
                            handle.setAttribute('data-dragging', '');
                        }
                    },
                    onDrag: function (event) {
                        if (!gesture.active) return;
                        var pt = pointFrom(event);
                        if (!pt) return;
                        if (gesture.touch && !gesture.locked) {
                            var dx = Math.abs(pt.x - gesture.sx);
                            var dy = Math.abs(pt.y - gesture.sy);
                            /* Undecided or vertical: commit nothing. Vertical
                               gestures are the page's to scroll. */
                            if (dy > dx && dy > 8) {
                                gesture.active = false;
                                this.endDrag(event);
                                return;
                            }
                            if (dx < 6 || dx <= dy) return;
                            gesture.locked = true;
                            handle.setAttribute('data-dragging', '');
                        }
                        setP(pFromClientX(pt.x) - gesture.off, false);
                    },
                    onRelease: function () {
                        var wasScrubbing = gesture.active && (gesture.locked || !gesture.touch);
                        gesture.active = false;
                        gesture.locked = false;
                        handle.removeAttribute('data-dragging');
                        /* With InertiaPlugin on the page the board glides to a
                           stop; without it, it simply stays put. */
                        if (wasScrubbing && hasInertia) {
                            gsap.to(state, {
                                inertia: { p: { min: MIN, max: MAX } },
                                duration: { max: 0.7 },
                                onUpdate: render
                            });
                        }
                    }
                })[0];
            }

            /* A Draggable is invisible to a keyboard; role="slider"
               promises the arrow keys work. */
            on(handle, 'keydown', function (event) {
                var handled = true;
                switch (event.key) {
                    case 'ArrowLeft':
                    case 'ArrowDown':  setP(state.p - 4, false); break;
                    case 'ArrowRight':
                    case 'ArrowUp':    setP(state.p + 4, false); break;
                    case 'PageDown':   setP(state.p - 20, false); break;
                    case 'PageUp':     setP(state.p + 20, false); break;
                    case 'Home':       setP(MIN, false); break;
                    case 'End':        setP(MAX, false); break;
                    default: handled = false;
                }
                if (handled) event.preventDefault();
            }, keyHandlers);

            render();

            return {
                comp: comp,
                state: state,
                start: start,
                render: render,
                setP: setP,
                kill: function () { if (drag) drag.kill(); }
            };
        })();

        /* ==================================================
           MOTION
           Keyed only on reduced motion, so exactly one branch
           runs on every device.
           ================================================== */
        var mm = gsap.matchMedia();

        mm.add('(prefers-reduced-motion: no-preference)', function () {

            /* ---- Hero load sequence ---- */
            var lines = gsap.utils.toArray('.pl');
            if (lines.length) {
                /* CSS parks the lines at translateY(112%), which GSAP
                   reads as a pixel y; the tween sets yPercent AND y so
                   no parsed pixel offset is left behind. */
                gsap.fromTo(lines,
                    { yPercent: 112, y: 0 },
                    { yPercent: 0, y: 0, duration: 0.9, stagger: 0.09, ease: 'power3.out' });
            }

            var thread = document.querySelector('[data-thread]');
            if (thread && typeof thread.getTotalLength === 'function') {
                var len = thread.getTotalLength();
                gsap.fromTo(thread,
                    { strokeDasharray: len, strokeDashoffset: len },
                    { strokeDashoffset: 0, duration: 1.6, delay: 0.5, ease: 'power2.inOut' });
            }

            var fill = document.querySelector('[data-counter-fill]');
            if (fill) {
                gsap.fromTo(fill, { scaleX: 0 }, { scaleX: 1, duration: 1.4, delay: 0.4, ease: 'power3.out' });
            }

            /* Everything below scrolls into life; without ScrollTrigger
               on the page it must simply be visible instead. */
            if (typeof ScrollTrigger === 'undefined') {
                gsap.set('[data-fade]', { opacity: 1 });
                return;
            }

            /* ---- Scroll reveals ---- */
            gsap.utils.toArray('[data-fade]').forEach(function (el) {
                gsap.fromTo(el,
                    { opacity: 0, y: 18 },
                    {
                        opacity: 1, y: 0, duration: 0.8, ease: 'power2.out',
                        scrollTrigger: { trigger: el, start: 'top 88%', once: true }
                    });
            });

            /* ---- Count-ups ---- */
            gsap.utils.toArray('[data-count]').forEach(function (el) {
                var target = parseFloat(el.getAttribute('data-count-to'));
                if (!isFinite(target)) return;
                var decimals = parseInt(el.getAttribute('data-count-decimals'), 10) || 0;
                var proxy = { v: 0 };
                gsap.to(proxy, {
                    v: target,
                    duration: 1.6,
                    ease: 'power2.out',
                    scrollTrigger: { trigger: el, start: 'top 88%', once: true },
                    onUpdate: function () {
                        if (!el.isConnected) return;
                        el.innerHTML = fmtDisplay(proxy.v, decimals);
                    }
                });
            });

            /* ---- Comparator intro: one sweep from nearly-closed to its
               resting split, so the interaction announces itself. ---- */
            if (comparator) {
                ScrollTrigger.create({
                    trigger: comparator.comp,
                    start: 'top 75%',
                    once: true,
                    onEnter: function () {
                        gsap.fromTo(comparator.state,
                            { p: 6 },
                            { p: comparator.start, duration: 1.3, ease: 'power2.inOut', onUpdate: comparator.render });
                    }
                });
            }

            return function cleanup() {
                if (typeof ScrollTrigger !== 'undefined') {
                    ScrollTrigger.getAll().forEach(function (t) { t.kill(); });
                }
            };
        });

        mm.add('(prefers-reduced-motion: reduce)', function () {
            /* The markup already holds every final value; just make sure
               nothing stays parked in a pre-animation state. */
            gsap.set('[data-fade]', { opacity: 1, y: 0 });
            gsap.set('.pl', { yPercent: 0, y: 0 });
            if (comparator) comparator.render();
        });
    });

    /* ==================================================
       THE DONATION FORM
       A front end only: it validates, words the outcome,
       confirms - and says it is not connected to a payment
       provider. Wire the submit to yours (see README).
       ================================================== */
    (function initGiveForm() {
        var form = document.querySelector('[data-give-form]');
        if (!form) return;

        var radios = Array.prototype.slice.call(form.querySelectorAll('input[name="amount"]'));
        var freqs = Array.prototype.slice.call(form.querySelectorAll('input[name="freq"]'));
        var otherWrap = form.querySelector('[data-gf-other]');
        var otherInput = form.querySelector('#gf-other-amount');
        var outcome = form.querySelector('[data-gf-outcome]');
        var submit = form.querySelector('[data-gf-submit]');
        var done = form.querySelector('[data-gf-done]');

        function checked(list) {
            for (var i = 0; i < list.length; i++) {
                if (list[i].checked) return list[i].value;
            }
            return null;
        }

        function amount() {
            var v = checked(radios);
            if (v === 'other') {
                var n = otherInput ? parseFloat(otherInput.value) : NaN;
                return isFinite(n) && n > 0 ? n : null;
            }
            var n2 = parseFloat(v);
            return isFinite(n2) ? n2 : null;
        }

        function outcomeFor(a) {
            if (a === null) return 'Pick an amount and see what it does.';
            for (var i = 0; i < TIER_MATH.length; i++) {
                var band = TIER_MATH[i];
                if (a >= band.from) {
                    var n = Math.round((a / band.per) * 10) / 10;
                    if (n >= 10 || n % 1 === 0) n = Math.round(n);
                    return '£' + fmt(a, 0) + ' ' + band.text(n);
                }
            }
            return '';
        }

        function refresh() {
            var a = amount();
            var monthly = checked(freqs) === 'monthly';
            if (otherWrap) otherWrap.hidden = checked(radios) !== 'other';
            if (outcome) {
                var line = outcomeFor(a);
                if (monthly && a !== null) line += ' Every month.';
                outcome.textContent = line;
            }
            if (submit) {
                submit.textContent = a === null
                    ? 'Give to the appeal'
                    : 'Give £' + fmt(a, 0) + (monthly ? ' monthly' : ' once');
            }
        }

        radios.forEach(function (r) { on(r, 'change', refresh, inputHandlers); });
        freqs.forEach(function (r) { on(r, 'change', refresh, inputHandlers); });
        on(otherInput, 'input', refresh, inputHandlers);

        on(form, 'submit', function (event) {
            event.preventDefault();
            var a = amount();
            if (a === null) {
                if (outcome) outcome.textContent = 'Enter an amount first.';
                if (otherInput && checked(radios) === 'other') otherInput.focus();
                return;
            }
            if (done) {
                var monthly = checked(freqs) === 'monthly';
                done.textContent = 'Thank you. £' + fmt(a, 0) +
                    (monthly ? ' a month' : '') +
                    ' is promised to the reach. This form is not yet connected to a ' +
                    'payment provider, so nothing has been charged - it confirms the ' +
                    'pledge and says so.';
                done.hidden = false;
            }
            if (submit) submit.disabled = true;
        }, submitHandlers);

        /* The tier cards' "Give £X" buttons preselect the amount on the
           way down to the form. */
        gsap.utils.toArray('[data-give]').forEach(function (btn) {
            on(btn, 'click', function () {
                var v = btn.getAttribute('data-give');
                var match = null;
                for (var i = 0; i < radios.length; i++) {
                    if (radios[i].value === v) { match = radios[i]; break; }
                }
                if (match) {
                    match.checked = true;
                } else {
                    for (var j = 0; j < radios.length; j++) {
                        if (radios[j].value === 'other') radios[j].checked = true;
                    }
                    if (otherInput) otherInput.value = v;
                }
                if (submit) submit.disabled = false;
                if (done) done.hidden = true;
                refresh();
            }, clickHandlers);
        });

        refresh();
    })();

    window.gsapContext = ctx;

    window.addEventListener('beforeunload', function () {
        if (comparator) comparator.kill();
        offAll(clickHandlers);
        offAll(keyHandlers);
        offAll(inputHandlers);
        offAll(submitHandlers);
        if (ctx) ctx.kill();
        if (lenisTick) gsap.ticker.remove(lenisTick);
        if (lenis) lenis.destroy();
    });
});
var TIER_MATH=[{from:60,per:60,text:function(e){return 1===e?"buys a tonne of washed gravel for a spawning riffle.":"buys "+e+" tonnes of washed gravel for spawning riffles."}},{from:25,per:6.25,text:function(e){return"fences and plants "+e+" metres of bank."}},{from:1,per:.4,text:function(e){return"rears and releases "+e+" trout fry below the weir."}}];"undefined"!=typeof gsap&&("undefined"!=typeof ScrollTrigger&&gsap.registerPlugin(ScrollTrigger),"undefined"!=typeof Draggable&&gsap.registerPlugin(Draggable),"undefined"!=typeof InertiaPlugin&&gsap.registerPlugin(InertiaPlugin),document.documentElement.classList.add("has-js")),function(e){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e):e()}(function(){if("undefined"!=typeof gsap){var e=null,t=null;"undefined"!=typeof Lenis&&(e=new Lenis({autoRaf:!1}),"undefined"!=typeof ScrollTrigger&&e.on("scroll",ScrollTrigger.update),t=function(t){e.raf(1e3*t)},gsap.ticker.add(t),gsap.ticker.lagSmoothing(0));var r=new Map,n=new Map,a=new Map,o=new Map,i=null,u=gsap.context(function(){i=function(){var e=document.querySelector("[data-comparator]");if(!e)return null;var t=e.querySelector("[data-comp-handle]"),r=e.querySelector(".comp-pane--before");if(!t||!r)return null;e.classList.add("comp--ready");var a=100,o=parseFloat(e.getAttribute("data-comp-start"));isFinite(o)||(o=46);var i={p:o=Math.max(0,Math.min(a,o))};function u(){var e=i.p;r.style.clipPath="inset(0 "+(100-e)+"% 0 0)",t.style.left=e+"%",t.setAttribute("aria-valuenow",String(Math.round(e))),t.setAttribute("aria-valuetext",Math.round(100-e)+"% of the restored river shown")}function l(e,t){e=Math.max(0,Math.min(a,e)),gsap.killTweensOf(i),t?gsap.to(i,{p:e,duration:.4,ease:"power2.out",onUpdate:u}):(i.p=e,u())}function s(t){var r=e.getBoundingClientRect();return r.width?(t-r.left)/r.width*100:i.p}function f(e){if(!e)return null;if("number"==typeof e.clientX)return{x:e.clientX,y:e.clientY};var t=e.touches&&e.touches[0]||e.changedTouches&&e.changedTouches[0];return t&&"number"==typeof t.clientX?{x:t.clientX,y:t.clientY}:null}var d=null,g={active:!1,locked:!1,touch:!1,sx:0,sy:0,off:0},p="undefined"!=typeof InertiaPlugin;if(p&&InertiaPlugin.track(i,"p"),"undefined"!=typeof Draggable){var h=document.createElement("div");d=Draggable.create(h,{trigger:e,type:"x",allowNativeTouchScrolling:!0,onPress:function(e){var r=f(e);if(g.active=!!r,g.locked=!1,g.touch=!!e&&("touch"===e.pointerType||"string"==typeof e.type&&0===e.type.indexOf("touch")),g.off=0,r){g.sx=r.x,g.sy=r.y;var n=e.target&&t.contains(e.target);n&&(g.off=s(r.x)-i.p),g.touch||(n||l(s(r.x),!0),t.setAttribute("data-dragging",""))}},onDrag:function(e){if(g.active){var r=f(e);if(r){if(g.touch&&!g.locked){var n=Math.abs(r.x-g.sx),a=Math.abs(r.y-g.sy);if(a>n&&a>8)return g.active=!1,void this.endDrag(e);if(n<6||n<=a)return;g.locked=!0,t.setAttribute("data-dragging","")}l(s(r.x)-g.off,!1)}}},onRelease:function(){var e=g.active&&(g.locked||!g.touch);g.active=!1,g.locked=!1,t.removeAttribute("data-dragging"),e&&p&&gsap.to(i,{inertia:{p:{min:0,max:a}},duration:{max:.7},onUpdate:u})}})[0]}return c(t,"keydown",function(e){var t=!0;switch(e.key){case"ArrowLeft":case"ArrowDown":l(i.p-4,!1);break;case"ArrowRight":case"ArrowUp":l(i.p+4,!1);break;case"PageDown":l(i.p-20,!1);break;case"PageUp":l(i.p+20,!1);break;case"Home":l(0,!1);break;case"End":l(a,!1);break;default:t=!1}t&&e.preventDefault()},n),u(),{comp:e,state:i,start:o,render:u,setP:l,kill:function(){d&&d.kill()}}}();var e=gsap.matchMedia();e.add("(prefers-reduced-motion: no-preference)",function(){var e=gsap.utils.toArray(".pl");e.length&&gsap.fromTo(e,{yPercent:112,y:0},{yPercent:0,y:0,duration:.9,stagger:.09,ease:"power3.out"});var t=document.querySelector("[data-thread]");if(t&&"function"==typeof t.getTotalLength){var r=t.getTotalLength();gsap.fromTo(t,{strokeDasharray:r,strokeDashoffset:r},{strokeDashoffset:0,duration:1.6,delay:.5,ease:"power2.inOut"})}var n=document.querySelector("[data-counter-fill]");if(n&&gsap.fromTo(n,{scaleX:0},{scaleX:1,duration:1.4,delay:.4,ease:"power3.out"}),"undefined"!=typeof ScrollTrigger)return gsap.utils.toArray("[data-fade]").forEach(function(e){gsap.fromTo(e,{opacity:0,y:18},{opacity:1,y:0,duration:.8,ease:"power2.out",scrollTrigger:{trigger:e,start:"top 88%",once:!0}})}),gsap.utils.toArray("[data-count]").forEach(function(e){var t=parseFloat(e.getAttribute("data-count-to"));if(isFinite(t)){var r=parseInt(e.getAttribute("data-count-decimals"),10)||0,n={v:0};gsap.to(n,{v:t,duration:1.6,ease:"power2.out",scrollTrigger:{trigger:e,start:"top 88%",once:!0},onUpdate:function(){e.isConnected&&(e.innerHTML=function(e,t){return s(e,t).replace(/[,.]/g,'<span class="fig-sep">$&</span>')}(n.v,r))}})}}),i&&ScrollTrigger.create({trigger:i.comp,start:"top 75%",once:!0,onEnter:function(){gsap.fromTo(i.state,{p:6},{p:i.start,duration:1.3,ease:"power2.inOut",onUpdate:i.render})}}),function(){"undefined"!=typeof ScrollTrigger&&ScrollTrigger.getAll().forEach(function(e){e.kill()})};gsap.set("[data-fade]",{opacity:1})}),e.add("(prefers-reduced-motion: reduce)",function(){gsap.set("[data-fade]",{opacity:1,y:0}),gsap.set(".pl",{yPercent:0,y:0}),i&&i.render()})});!function(){var e=document.querySelector("[data-give-form]");if(e){var t=Array.prototype.slice.call(e.querySelectorAll('input[name="amount"]')),n=Array.prototype.slice.call(e.querySelectorAll('input[name="freq"]')),i=e.querySelector("[data-gf-other]"),u=e.querySelector("#gf-other-amount"),l=e.querySelector("[data-gf-outcome]"),f=e.querySelector("[data-gf-submit]"),d=e.querySelector("[data-gf-done]");t.forEach(function(e){c(e,"change",h,a)}),n.forEach(function(e){c(e,"change",h,a)}),c(u,"input",h,a),c(e,"submit",function(e){e.preventDefault();var r=p();if(null===r)return l&&(l.textContent="Enter an amount first."),void(u&&"other"===g(t)&&u.focus());if(d){var a="monthly"===g(n);d.textContent="Thank you. £"+s(r,0)+(a?" a month":"")+" is promised to the reach. This form is not yet connected to a payment provider, so nothing has been charged - it confirms the pledge and says so.",d.hidden=!1}f&&(f.disabled=!0)},o),gsap.utils.toArray("[data-give]").forEach(function(e){c(e,"click",function(){for(var r=e.getAttribute("data-give"),n=null,a=0;a<t.length;a++)if(t[a].value===r){n=t[a];break}if(n)n.checked=!0;else{for(var o=0;o<t.length;o++)"other"===t[o].value&&(t[o].checked=!0);u&&(u.value=r)}f&&(f.disabled=!1),d&&(d.hidden=!0),h()},r)}),h()}function g(e){for(var t=0;t<e.length;t++)if(e[t].checked)return e[t].value;return null}function p(){var e=g(t);if("other"===e){var r=u?parseFloat(u.value):NaN;return isFinite(r)&&r>0?r:null}var n=parseFloat(e);return isFinite(n)?n:null}function h(){var e=p(),r="monthly"===g(n);if(i&&(i.hidden="other"!==g(t)),l){var a=function(e){if(null===e)return"Pick an amount and see what it does.";for(var t=0;t<TIER_MATH.length;t++){var r=TIER_MATH[t];if(e>=r.from){var n=Math.round(e/r.per*10)/10;return(n>=10||n%1==0)&&(n=Math.round(n)),"£"+s(e,0)+" "+r.text(n)}}return""}(e);r&&null!==e&&(a+=" Every month."),l.textContent=a}f&&(f.textContent=null===e?"Give to the appeal":"Give £"+s(e,0)+(r?" monthly":" once"))}}(),window.gsapContext=u,window.addEventListener("beforeunload",function(){i&&i.kill(),l(r),l(n),l(a),l(o),u&&u.kill(),t&&gsap.ticker.remove(t),e&&e.destroy()})}function c(e,t,r,n){if(e){e.addEventListener(t,r);var a=n.get(e)||[];a.push({type:t,fn:r}),n.set(e,a)}}function l(e){e.forEach(function(e,t){e.forEach(function(e){t.removeEventListener(e.type,e.fn)})}),e.clear()}function s(e,t){var r=e.toFixed(t).split(".");return r[0]=r[0].replace(/\B(?=(\d{3})+(?!\d))/g,","),r.join(".")}});
/* ============================================
   BRENNA RIVERS TRUST - CHARITY CAMPAIGN TEMPLATE

   A river-restoration appeal that refuses both charity
   defaults: no NGO blue with smiling stock photos, and
   no guilt-dark ground with a red DONATE. The ground is
   the river's own colour - a saturated viridian - chalk
   does the talking, and the donate buttons are chalk
   fills with river ink, because the ask should read as
   the calmest thing on the page, not the loudest.

   Type is a placard system: Barlow Condensed for the
   tall protest-board caps, Source Serif 4 for the prose,
   and B612 Mono for every figure on the page - money,
   gauge readings, counts, dates - because a charity that
   leads with measurements should set them like readings.
   ============================================ */

/* ---- Why these values, and what was measured ----------------------

   Three hues: river (the viridian ground and its two surface values),
   chalk (type and paper), water (one aquatic accent). Water is
   RATIONED: hairline rules, the gauge staff ticks, the hero thread,
   small mono labels - never headings, never body copy. Colour on this
   page means "a reading", not "look here".

   Measured, composited, at the size used:

     TYPE ON THE GREEN GROUNDS
     chalk          on river         6.97:1
     chalk          on river-deep    8.99:1
     chalk          on river-lift    5.94:1
     dim (chalk.86) on river         5.60:1   ledes, captions, nav,
     dim (chalk.86) on river-lift    4.85:1   tier copy, footer
     dim (chalk.86) on river-deep    7.11:1   stat captions
     water          on river         4.94:1   small mono labels only
     water          on river-deep    6.38:1   stat units

     TYPE ON CHALK (the donate panel, buttons, comparator tags)
     ink (river-deep) on chalk       8.99:1
     ink            on chalk-deep    7.82:1
     ink-dim (ink.85) on chalk       6.10:1   form notes, fine print
     ink-dim (ink.85) on chalk-deep  5.47:1
     river          on chalk         6.97:1

     NON-TEXT (3:1 floor)
     water rules    on river         4.94:1
     water rules    on river-lift    4.21:1
     chalk bar fill on deep track    8.99:1

   BANNED:
   - Orange, red and charity blue, anywhere, for anything. The only
     warm pixels on the page are inside the photographs, where the
     silt is the argument.
   - Water as TYPE on river-lift (4.21:1). On the raised cards it may
     only be a rule or a mark; its type homes are river and river-deep.
   - Anything dimmer than --dim as type on any green ground, and
     anything dimmer than --ink-dim as type on chalk. There is no
     third, fainter tier; quietness is carried by the mono voice's
     size and letterspacing instead.
   - Lightening --river past about #1A6355 pulls solid chalk toward
     the dim tier's floor and takes --water below 4.5:1 as a label;
     darkening --water past about #7CC7B2 does the same from the
     other side. The pair is measured together - move one, re-measure
     both.

   The lift and deep surfaces sit close to the ground on purpose
   (1.2-1.3:1): they are tonal steps, always paired with a hairline
   or a layout seam, never the only signal of anything.
   -------------------------------------------------------------- */

:root {
    /* ---- The palette ----------------------------------------------
       Three hues; everything else is one of them at another value or
       opacity. Change these three (and their RGB triples, used for
       every translucent rule and wash) and the site reskins. */

    --river: #14584A;         /* the ground: the river's viridian */
    --river-deep: #0E463A;    /* recessed bands, and ink on chalk */
    --river-lift: #1B6355;    /* raised cards on the ground */
    --chalk: #EFEBDC;         /* type on green; the donate panel */
    --chalk-deep: #E2DCC7;    /* chalk's own second value: wells, rules */
    --water: #8AD6C2;         /* the one aquatic accent, rationed */

    --river-rgb: 20, 88, 74;
    --chalk-rgb: 239, 235, 220;
    --water-rgb: 138, 214, 194;
    --ink-rgb: 14, 70, 58;

    /* Derived type tiers - the measured pairs in the table above */
    --ink: var(--river-deep);                    /* type on chalk */
    --dim: rgba(var(--chalk-rgb), 0.86);         /* quiet type on green */
    --ink-dim: rgba(var(--ink-rgb), 0.85);       /* quiet type on chalk */

    /* Hairlines: frames and seams, never a control's only state */
    --rule: rgba(var(--chalk-rgb), 0.18);
    --rule-strong: rgba(var(--chalk-rgb), 0.32);
    --rule-ink: rgba(var(--ink-rgb), 0.22);
    --wash: rgba(var(--chalk-rgb), 0.06);

    --font-display: 'Barlow Condensed', 'Arial Narrow', sans-serif;
    --font-body: 'Source Serif 4', Georgia, serif;
    --font-mono: 'B612 Mono', 'Courier New', monospace;

    --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
    --content: 1160px;
}

/* ---- Reset and ground ---- */

* { margin: 0; padding: 0; box-sizing: border-box; }

html { scroll-behavior: smooth; }

/* Anchored sections stop clear of the sticky nav */
section, footer, [id] { scroll-margin-top: 5rem; }

body {
    background: var(--river);
    color: var(--chalk);
    font-family: var(--font-body);
    font-size: 1.0625rem;
    line-height: 1.6;
    -webkit-font-smoothing: antialiased;
    overflow-x: clip;
}

::selection { background: var(--water); color: var(--river-deep); }

::-webkit-scrollbar { width: 10px; }
::-webkit-scrollbar-track { background: var(--river-deep); }
::-webkit-scrollbar-thumb { background: rgba(var(--chalk-rgb), 0.35); border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: rgba(var(--chalk-rgb), 0.5); }

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

a { color: inherit; }

a:focus-visible,
button:focus-visible,
input:focus-visible,
[tabindex]:focus-visible {
    outline: 2px solid var(--water);
    outline-offset: 3px;
}

.mono { font-family: var(--font-mono); }

.sr-only {
    position: absolute; 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: 1rem; top: -4rem; z-index: 100;
    background: var(--chalk); color: var(--ink);
    font-family: var(--font-mono); font-size: 0.85rem;
    padding: 0.7rem 1.1rem; text-decoration: none;
    transition: top 0.2s;
}
.skip:focus { top: 1rem; }

/* ---- Shared section chrome ---- */

.section-inner {
    max-width: var(--content);
    margin: 0 auto;
    padding-left: clamp(1.25rem, 4vw, 2.5rem);
    padding-right: clamp(1.25rem, 4vw, 2.5rem);
}

.eyebrow {
    font-family: var(--font-mono);
    font-size: 0.78rem;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--water);
    margin-bottom: 1.1rem;
}

.section-title {
    font-family: var(--font-display);
    font-weight: 600;
    font-size: clamp(2.2rem, 4.6vw, 3.4rem);
    line-height: 1;
    letter-spacing: 0.01em;
    text-transform: uppercase;
    margin-bottom: 1.1rem;
}

.section-lede {
    max-width: 56ch;
    color: var(--dim);
}

.section-head { margin-bottom: clamp(2.5rem, 5vw, 4rem); }

/* ---- Buttons ---- */

.btn {
    display: inline-block;
    font-family: var(--font-mono);
    font-size: 0.9rem;
    line-height: 1;
    text-decoration: none;
    text-align: center;
    padding: 0.95rem 1.6rem;
    border: 1px solid transparent;
    border-radius: 2px;
    cursor: pointer;
    transition: transform 0.18s var(--ease-out), background-color 0.18s, border-color 0.18s;
}
.btn:hover { transform: translateY(-2px); }

.btn--chalk { background: var(--chalk); color: var(--ink); }
.btn--chalk:hover { background: var(--chalk-deep); }

.btn--ghost {
    background: transparent;
    color: var(--chalk);
    border-color: var(--rule-strong);
}
.btn--ghost:hover { background: var(--wash); border-color: var(--chalk); }

.btn--river { background: var(--river); color: var(--chalk); border: 0; }
.btn--river:hover { background: var(--river-deep); }

.btn--small { padding: 0.7rem 1.1rem; font-size: 0.82rem; }

/* ---- Nav ---- */

.nav {
    position: sticky;
    top: 0;
    z-index: 50;
    display: flex;
    align-items: center;
    gap: 2rem;
    padding: 0.9rem clamp(1.25rem, 4vw, 2.5rem);
    background: rgba(var(--river-rgb), 0.92);
    backdrop-filter: blur(8px);
    border-bottom: 1px solid var(--rule);
}

.nav-mark {
    display: flex;
    align-items: center;
    gap: 0.7rem;
    text-decoration: none;
}
.nav-mark-rule {
    width: 4px; height: 2.1rem;
    background: var(--water);
}
.nav-mark-text {
    font-family: var(--font-display);
    font-weight: 700;
    font-size: 1.02rem;
    line-height: 1.02;
    text-transform: uppercase;
    letter-spacing: 0.06em;
}

.nav-links {
    display: flex;
    gap: 1.6rem;
    list-style: none;
    margin-left: auto;
}
.nav-links a {
    font-family: var(--font-mono);
    font-size: 0.82rem;
    text-decoration: none;
    color: var(--dim);
    transition: color 0.15s;
}
.nav-links a:hover, .nav-links a:focus-visible { color: var(--chalk); }

/* ---- Hero: the placard ---- */

.hero { padding: clamp(3.5rem, 8vw, 6.5rem) 0 clamp(4rem, 9vw, 7rem); }

.hero-inner {
    max-width: var(--content);
    margin: 0 auto;
    padding: 0 clamp(1.25rem, 4vw, 2.5rem);
    display: grid;
    grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
    gap: clamp(2.5rem, 6vw, 5rem);
    align-items: start;
}

.placard {
    font-family: var(--font-display);
    font-weight: 700;
    font-size: clamp(3.4rem, 8.4vw, 6rem);
    line-height: 0.94;
    letter-spacing: 0.005em;
    text-transform: uppercase;
    margin: 0 0 1.6rem;
}

.mask { display: block; overflow: hidden; padding-bottom: 0.06em; }
.pl { display: block; will-change: transform; }
html.has-js .pl { transform: translateY(112%); }

.hero-thread { display: block; width: min(100%, 38rem); margin-top: 0.6rem; color: var(--water); opacity: 0.85; }

.standfirst {
    font-size: 1.14rem;
    color: var(--dim);
    margin-bottom: 2.2rem;
}

/* The raised-so-far counter */
.counter {
    background: var(--river-lift);
    border: 1px solid var(--rule);
    border-radius: 3px;
    padding: 1.5rem 1.6rem 1.4rem;
    margin-bottom: 2rem;
}

.counter-raised {
    display: flex;
    align-items: baseline;
    gap: 0.8rem;
    flex-wrap: wrap;
    margin-bottom: 0.9rem;
}
.counter-fig {
    font-size: clamp(2rem, 4vw, 2.7rem);
    font-weight: 700;
    line-height: 1;
}

/* B612 Mono gives , and . a full character cell, which at display size opens
   a gap that reads as a typo ("£41, 380"). The count-up wraps separators in
   .fig-sep; pull the following digit back into reach. Display figures only —
   body-size mono keeps the face's natural spacing. */
.counter-fig .fig-sep,
.stat-fig .fig-sep { margin-right: -0.34ch; }
.counter-of { color: var(--dim); font-size: 1rem; }

.counter-bar {
    height: 10px;
    background: var(--river-deep);
    border-radius: 5px;
    overflow: hidden;
    margin-bottom: 0.8rem;
}
.counter-fill {
    height: 100%;
    background: var(--chalk);
    border-radius: 5px;
    transform-origin: left center;
}

.counter-meta { font-size: 0.78rem; letter-spacing: 0.04em; color: var(--dim); }

.hero-ctas { display: flex; gap: 0.9rem; flex-wrap: wrap; }

/* ---- The problem: gauge figures on the deep band ---- */

.problem {
    background: var(--river-deep);
    border-top: 1px solid var(--rule);
    border-bottom: 1px solid var(--rule);
    padding: clamp(4rem, 8vw, 6.5rem) 0;
}

.stats {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
    gap: clamp(1.8rem, 4vw, 3rem);
}

.stat {
    border-top: 2px solid var(--water);
    padding-top: 1.1rem;
}

.stat-fig {
    font-size: clamp(2.6rem, 5vw, 3.6rem);
    font-weight: 700;
    line-height: 1;
    margin-bottom: 0.7rem;
}
.stat-unit {
    font-size: 0.85rem;
    font-weight: 400;
    color: var(--water);
    margin-left: 0.5rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
}
.stat-caption { color: var(--dim); font-size: 0.98rem; line-height: 1.55; }
.stat-caption em { font-style: italic; }

/* ---- The work: before/after comparator ---- */

.work { padding: clamp(4rem, 8vw, 6.5rem) 0 0; }
.work-head { margin-bottom: clamp(2.2rem, 4vw, 3.2rem); }

/* No JavaScript: the two panes are a plain stacked pair with their
   captions visible - the full story with nothing to drag. The script
   adds .comp--ready and the panes become one clipped frame. */
.comp {
    position: relative;
    display: grid;
    gap: 2px;
    background: var(--river-deep);
}

.comp-pane { position: relative; margin: 0; }
.comp-pane img {
    width: 100%;
    height: clamp(300px, 60vw, 460px);
    object-fit: cover;
    background: var(--river-deep);
}

.comp-tag {
    position: absolute;
    top: 1rem;
    display: inline-flex;
    align-items: baseline;
    gap: 0.55rem;
    background: var(--chalk);
    color: var(--ink);
    font-size: 0.88rem;
    padding: 0.45rem 0.8rem;
    border-radius: 2px;
    max-width: calc(100% - 5rem);
}
.comp-tag .mono { font-size: 0.78rem; font-weight: 700; }
.comp-tag--before { left: 1rem; }
.comp-tag--after { right: 1rem; }

.comp-staff { display: none; }

/* Upgraded, script-driven layout */
.comp--ready {
    display: block;
    height: clamp(340px, 62vh, 620px);
    overflow: hidden;
    touch-action: pan-y; /* vertical swipes belong to the page */
}
.comp--ready .comp-pane { position: absolute; inset: 0; }
.comp--ready .comp-pane img { width: 100%; height: 100%; }
.comp--ready .comp-pane--before {
    clip-path: inset(0 54% 0 0);
    z-index: 2;
}

.comp--ready .comp-staff {
    display: block;
    position: absolute;
    top: 0;
    bottom: 0;
    left: 46%;
    width: 44px;
    margin-left: -22px;
    z-index: 3;
    cursor: ew-resize;
    -webkit-tap-highlight-color: transparent;
}

/* The gauge board: a chalk depth staff with water ticks */
.staff-board {
    position: absolute;
    top: 0; bottom: 0;
    left: 50%;
    width: 30px;
    margin-left: -15px;
    background:
        repeating-linear-gradient(
            to top,
            transparent 0,
            transparent 9px,
            rgba(var(--ink-rgb), 0.28) 9px,
            rgba(var(--ink-rgb), 0.28) 10px,
            transparent 10px,
            transparent 40px,
            rgba(var(--ink-rgb), 0.55) 40px,
            rgba(var(--ink-rgb), 0.55) 42px,
            transparent 42px,
            transparent 80px
        ),
        var(--chalk);
    box-shadow: 0 0 0 1px rgba(var(--ink-rgb), 0.35), 0 2px 14px rgba(0, 0, 0, 0.3);
}

.staff-num {
    position: absolute;
    left: 0;
    width: 100%;
    text-align: center;
    font-family: var(--font-mono);
    font-size: 0.6rem;
    font-weight: 700;
    color: var(--ink);
    letter-spacing: -0.02em;
}
.staff-num--1 { top: 6%; }
.staff-num--2 { top: 24%; }
.staff-num--3 { top: 42%; }
.staff-num--4 { top: 68%; }
.staff-num--5 { top: 86%; }

.staff-grip {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 44px;
    height: 44px;
    transform: translate(-50%, -50%);
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--chalk);
    color: var(--ink);
    border-radius: 50%;
    box-shadow: 0 0 0 1px rgba(var(--ink-rgb), 0.35), 0 2px 12px rgba(0, 0, 0, 0.35);
    transition: transform 0.15s var(--ease-out);
}
.comp-staff:hover .staff-grip,
.comp-staff:focus-visible .staff-grip,
.comp-staff[data-dragging] .staff-grip { transform: translate(-50%, -50%) scale(1.08); }

.comp-help {
    color: var(--dim);
    font-size: 0.95rem;
    padding-top: 1.1rem;
    padding-bottom: 0;
}

/* The drag instruction only exists when the board does */
.js-only { display: none; }
html.has-js .js-only { display: inline; }
html.has-js .nojs-only { display: none; }

/* ---- What your money does ---- */

.money { padding: clamp(4rem, 8vw, 6.5rem) 0; }

.money-grid {
    display: grid;
    grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
    gap: clamp(2rem, 4vw, 3.5rem);
    align-items: start;
}

.tiers {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 1.2rem;
}

.tier {
    background: var(--river-lift);
    border: 1px solid var(--rule);
    border-radius: 3px;
    padding: 1.5rem 1.5rem 1.4rem;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 0.9rem;
}

.tier-price {
    font-size: 1.9rem;
    font-weight: 700;
    line-height: 1;
    padding-bottom: 0.7rem;
    border-bottom: 2px solid var(--water);
}
.tier-what { color: var(--dim); font-size: 0.98rem; flex: 1; }
.tier-what strong { color: var(--chalk); font-weight: 600; }

.money-photo img { border-radius: 3px; }
.money-photo figcaption,
.parties-photo figcaption,
.give-photo figcaption {
    font-size: 0.88rem;
    color: var(--dim);
    padding-top: 0.7rem;
    max-width: 44ch;
}

/* ---- Work parties ---- */

.parties {
    background: var(--river-deep);
    border-top: 1px solid var(--rule);
    border-bottom: 1px solid var(--rule);
    padding: clamp(4rem, 8vw, 6.5rem) 0;
}

.parties-grid {
    display: grid;
    grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
    gap: clamp(2rem, 4vw, 3.5rem);
    align-items: start;
}

.party-list { list-style: none; }

.party {
    display: flex;
    align-items: center;
    gap: 1.4rem;
    padding: 1.1rem 0;
    border-top: 1px solid var(--rule);
}
.party:last-child { border-bottom: 1px solid var(--rule); }

.party-date {
    font-size: 0.88rem;
    font-weight: 700;
    white-space: nowrap;
    color: var(--water);
    min-width: 7ch;
}
.party-body { flex: 1; min-width: 0; }
.party-task { font-size: 1.02rem; }
.party-meta { font-size: 0.75rem; letter-spacing: 0.06em; color: var(--dim); margin-top: 0.15rem; }

.party--full .party-task { color: var(--dim); }
.party-full-note {
    font-size: 0.78rem;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--dim);
    border: 1px solid var(--rule-strong);
    padding: 0.55rem 0.9rem;
    border-radius: 2px;
}

.parties-photo img { border-radius: 3px; }

/* ---- Donate: the chalk panel ---- */

.give { padding: clamp(4rem, 8vw, 6.5rem) 0; }

.give-grid {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
    gap: clamp(2.5rem, 5vw, 4.5rem);
    align-items: start;
}

.give-photo { margin-top: 2rem; }
.give-photo img { border-radius: 3px; }

.give-form {
    background: var(--chalk);
    color: var(--ink);
    border-radius: 4px;
    padding: clamp(1.6rem, 3vw, 2.4rem);
}

.give-form fieldset { border: 0; margin-bottom: 1.6rem; }

.gf-label {
    display: block;
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--ink);
    margin-bottom: 0.7rem;
}
legend.gf-label { padding: 0; }

.gf-amount-row { display: flex; gap: 0.6rem; flex-wrap: wrap; }

.gf-pill {
    position: relative;
    cursor: pointer;
}
.gf-pill input {
    position: absolute;
    opacity: 0;
    width: 100%; height: 100%;
    left: 0; top: 0;
    cursor: pointer;
}
.gf-pill span {
    display: inline-block;
    padding: 0.75rem 1.25rem;
    border: 1px solid var(--rule-ink);
    border-radius: 2px;
    font-size: 0.95rem;
    transition: background-color 0.15s, color 0.15s, border-color 0.15s;
}
.gf-pill:hover span { border-color: var(--ink); }
.gf-pill input:checked + span {
    background: var(--river-deep);
    color: var(--chalk);
    border-color: var(--river-deep);
}
.gf-pill input:focus-visible + span {
    outline: 2px solid var(--ink);
    outline-offset: 2px;
}

.gf-other { margin-top: 1rem; }
.gf-other[hidden] { display: none; }

.gf-outcome {
    margin-top: 1rem;
    font-size: 0.98rem;
    color: var(--ink-dim);
    font-style: italic;
    min-height: 1.6em;
}

.gf-field { margin-bottom: 1.3rem; }

.gf-input {
    width: 100%;
    font-family: var(--font-body);
    font-size: 1rem;
    color: var(--ink);
    background: rgba(var(--ink-rgb), 0.05);
    border: 1px solid var(--rule-ink);
    border-radius: 2px;
    padding: 0.8rem 0.9rem;
}
.gf-input:focus-visible { outline: 2px solid var(--ink); outline-offset: 1px; }
.gf-input::placeholder { color: var(--ink-dim); opacity: 1; }
#gf-other-amount { max-width: 12rem; font-family: var(--font-mono); }

.gf-giftaid {
    display: flex;
    gap: 0.8rem;
    align-items: flex-start;
    font-size: 0.95rem;
    color: var(--ink-dim);
    margin-bottom: 1.6rem;
    cursor: pointer;
}
.gf-giftaid input {
    flex-shrink: 0;
    width: 1.15rem; height: 1.15rem;
    margin-top: 0.2rem;
    accent-color: var(--river-deep);
    cursor: pointer;
}

.gf-submit { width: 100%; font-size: 1rem; padding: 1.1rem 1.5rem; }

.gf-note {
    font-size: 0.88rem;
    color: var(--ink-dim);
    margin-top: 1rem;
}

.gf-done {
    margin-top: 1.4rem;
    border-top: 2px solid var(--river-deep);
    padding-top: 1.2rem;
    font-size: 1.02rem;
}
.gf-done[hidden] { display: none; }

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

.footer {
    background: var(--river-deep);
    border-top: 1px solid var(--rule);
    padding: clamp(3rem, 6vw, 4.5rem) 0;
}

.footer-grid {
    display: grid;
    grid-template-columns: 1.3fr 1fr 1fr 1fr;
    gap: clamp(1.8rem, 4vw, 3rem);
}

.footer-mark {
    font-family: var(--font-display);
    font-weight: 700;
    font-size: 1.4rem;
    line-height: 1.02;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    margin-bottom: 0.8rem;
}

.footer-head {
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--water);
    margin-bottom: 0.8rem;
}

.footer-small { font-size: 0.92rem; color: var(--dim); }
.footer-small a { color: var(--dim); }
.footer-small a:hover { color: var(--chalk); }

/* ---- Reveals (pre-hidden only once the script is known present) ---- */

html.has-js [data-fade] { opacity: 0; }

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

@media (max-width: 900px) {
    .hero-inner { grid-template-columns: 1fr; }
    .money-grid { grid-template-columns: 1fr; }
    .money-photo { max-width: 30rem; }
    .parties-grid { grid-template-columns: 1fr; }
    .parties-photo { order: -1; max-width: 36rem; }
    .give-grid { grid-template-columns: 1fr; }
    .give-photo { max-width: 30rem; }
    .footer-grid { grid-template-columns: 1fr 1fr; }
}

@media (max-width: 640px) {
    .nav { gap: 1rem; }
    .nav-links { display: none; }
    .nav-cta { margin-left: auto; }
    .placard { font-size: clamp(3rem, 14vw, 4.2rem); }
    .party { flex-wrap: wrap; gap: 0.6rem 1.2rem; }
    .party-body { flex-basis: 100%; order: 2; }
    .party .btn, .party .party-full-note { order: 3; }
    .footer-grid { grid-template-columns: 1fr; }
    .comp-tag { font-size: 0.8rem; max-width: calc(100% - 4rem); }
    .comp-tag--before { left: 0.7rem; }
    .comp-tag--after { right: 0.7rem; }
}

/* ---- Reduced motion: a complete static page ---- */

@media (prefers-reduced-motion: reduce) {
    html { scroll-behavior: auto; }
    html.has-js [data-fade] { opacity: 1; }
    html.has-js .pl { transform: none; }
    .btn, .staff-grip { transition: none; }
    .btn:hover { transform: none; }
}

A complete, deployable one-page appeal for a charity campaign, written as a river-restoration trust. Static files, no build step, no backend.

The page argues the way a good small charity argues: with measurements. A placard hero with a live raised-so-far counter, the problem in figures, a before/after photograph pair you wipe between, donation tiers priced in metres of bank and tonnes of gravel, work party dates, and a donation form that words out exactly what an amount buys.

The signature is the comparator. Two photographs of the same kind of river fill one frame; a draggable divider styled as a river gauge board clips between them. Drag it, arrow-key it, or swipe it on a phone; a vertical swipe on the board still scrolls the page, because the drag only commits once a touch gesture is clearly horizontal.


Quick Start

  1. Copy the whole folder onto your host. It is plain HTML, CSS, JS and WebP; anything that serves static files will do.
  2. Replace the campaign copy and figures in index.html. Every number on the page is plain markup: the counter, the stats, the tier prices, the work party dates.
  3. Replace the photographs in assets/img/ (see Images below).
  4. Repaint the three colours at the top of assets/style.css if you want a different palette (see Palette).
  5. Wire the donation form's submit to your payment provider (see The donation form).

There is nothing to install and nothing to compile.


Editing the campaign

Everything a visitor reads lives in index.html:

  • The counter. The raised figure and donor count are data-count spans: the text content is the final value shown without JavaScript, and data-count-to is the value the count-up animates to. Keep the two in agreement. The progress bar's width is an inline style="width: 69%"; set it to raised / target.
  • The stats band. Each stat is a <dt> figure and a <dd> caption. data-count-to takes decimals via data-count-decimals="1". A figure without data-count (the zero) simply never animates.
  • Tier cards. Each card's button carries data-give="25"; clicking it scrolls to the form and preselects that amount. Amounts that match a form preset select the radio; anything else fills the Other field.
  • Work parties. Each row is a list item with a mono date, a task, a places note and a mailto link. Mark a full date by adding the party--full class and swapping the link for the party-full-note span, as the December row shows.

Sections are independent: the script skips any section it does not find, so delete whole <section> blocks freely.


The comparator

<div class="comp" data-comparator data-comp-start="46">
Attribute Values Default What it does
data-comp-start 0100 46 Where the divider rests: the percentage of the frame showing the before photograph

The divider position is one number, and everything renders from it: the clip on the before pane, the board's left, and the slider's aria-valuenow.

Input behaviour:

Input What happens
Drag the board (mouse or pen) The wipe follows the pointer
Click anywhere on the frame The divider animates to the click
Horizontal swipe (touch) The wipe follows the finger
Vertical swipe, even starting on the board (touch) The page scrolls; the divider does not move
Arrow keys ±4. Page Up / Page Down ±20, Home / End to the extremes

The touch rules are deliberate: a touch press commits nothing, and the drag only starts scrubbing once the gesture is more horizontal than vertical. Coordinates are read from the raw event (null-safe) rather than GSAP's cached pointer, which reads 0 when the browser reclaims a gesture for scrolling.

Inertia (optional). Add one script tag next to the others and the board glides to a stop after a flick; without it the board simply stays where it is released:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/InertiaPlugin.min.js"></script>

Swapping the photographs. The pair must read as the same kind of subject (same scale, similar horizon) or the wipe reads as two unrelated pictures. Replace comp-before.webp and comp-after.webp (1600×900), update the alt text and the two figcaption tags, and keep both <figure> blocks: without JavaScript they render stacked with their labels, which is the fallback.

Without JavaScript the comparator is a labelled stacked pair and the drag instruction swaps for a caption describing it. Under reduced motion the comparator stays fully draggable, because it is a control rather than decoration, and only the intro sweep and the surrounding animation are dropped.


The donation form

The form is a front end. It validates, words the outcome, confirms, and tells the visitor nothing has been charged, because it is not connected to a payment provider until you connect one.

  • The outcome line ("£25 fences and plants 4 metres of bank") is computed by TIER_MATH at the top of assets/script.js: an array of bands, each with the minimum amount it applies from, a unit cost, and a function that words the outcome. Edit the costs and wording to match your charity's real arithmetic; the point of the line is that it is true.
  • Wiring the submit. Find the on(form, 'submit', ...) handler in initGiveForm and replace its confirmation body with a redirect or fetch() to your provider: Stripe Payment Links, JustGiving, GoCardless for the monthly option, or your own endpoint. The helper amount() returns the chosen amount as a number, and the freq radios distinguish one-off from monthly.
  • Gift Aid. The checkbox is a declaration only; pass its state to your provider or CRM. If your charity is not UK-registered, delete the label.

Palette

Three hues; everything else is one of them at another value or opacity. The tokens live at the top of assets/style.css with their measured contrast table in the comment above them:

--river: #14584A;         /* the ground */
--river-deep: #0E463A;    /* recessed bands, and ink on chalk */
--river-lift: #1B6355;    /* raised cards */
--chalk: #EFEBDC;         /* type on green; the donate panel */
--chalk-deep: #E2DCC7;    /* chalk's second value: wells, hover fills */
--water: #8AD6C2;         /* the one accent, rationed to small labels and rules */

--river-rgb: 20, 88, 74;
--chalk-rgb: 239, 235, 220;
--water-rgb: 138, 214, 194;
--ink-rgb: 14, 70, 58;

Every translucent value in the stylesheet resolves from the --*-rgb triples, so a reskin is an edit to the hex values and their matching triples; nothing else holds a colour. If you change them, re-measure the pairs listed in the comment: the header documents which combinations were measured and where the floors are.


Images

The photographs are bundled, optimised WebP derivatives with the template's cool viridian grade baked into the pixels, not original Unsplash files. assets/img-manifest.json records each source URL, output size, and grade.

  • To swap a photo: replace the file in assets/img/ with your own (same dimensions), or edit the src in the manifest and regenerate.
  • To regenerate the set (from the GSAP Vault repo): bun scripts/build-template-assets.ts charity-campaign-template --force. The script downloads each source, applies the crop and grade, and emits WebP into assets/img/.
  • Keep a background-color beneath full-bleed images (already in the CSS) so text never sits on nothing while they load.

Original photography, via Unsplash:


Accessibility

  • The comparator divider is a real role="slider" with aria-valuenow, aria-valuemin/max, and an aria-valuetext that reports how much of the restored view is shown. Its keydown handler implements the full slider keyboard contract.
  • Every campaign figure is present in the markup at its final value, so nothing depends on the count-ups running.
  • Reveal states are gated behind an html.has-js class that is only added once GSAP is confirmed present: a blocked CDN leaves a fully readable page.
  • Reduced motion is honoured twice, by a CSS media query and a gsap.matchMedia branch, and leaves a complete static page with the comparator still operable.
  • The donation form uses real labels, fieldsets and legends; the outcome line and the confirmation are polite live regions.
  • Focus is always visible: water outlines on green grounds, ink outlines on the chalk panel.
  • A skip link jumps straight to the donation form.

Browser Support

Modern browsers (ES5 syntax, CSS clip-path, inset, custom properties). Not compatible with IE11.

Dependencies

  • GSAP 3.12+ (core)
  • ScrollTrigger plugin (reveals and count-ups; the page stays fully readable without it)
  • Draggable plugin (the comparator drag; keyboard and click still work without it)
  • InertiaPlugin (optional: the board glides after a flick; not loaded by default)
  • Lenis (optional: smooth scrolling; the page works without it)

Your Cart

Your cart is empty

Browse Effects