Overview#
Nonprofit is a warm, trustworthy website for a charity, with a working donate page. The demo organisation is Clearwater Trust, a fictional clean-water charity working in nine countries. The site tells its story with impact counters, an appeal progress bar and long-form field stories, shows where money goes with a spending chart, and includes volunteering, events and a full donation flow with one-time and monthly giving. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.3. It builds to static HTML files with no UI framework and can be hosted anywhere. See it live at freeframe.dev/templates/nonprofit.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:4321With npm: npm install, then npm run dev.
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:4321 with hot reload |
pnpm build |
Static site in dist/ |
pnpm preview |
Serve dist/ locally after a build |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
Generate Astro's types, then type-check with tsc (TypeScript 7) |
pnpm lint |
ESLint (TypeScript and Astro rules) |
pnpm build:demo |
Build for /demos/astro/nonprofit into demo/ (only used for the demo on freeframe.dev) |
Pages#
Every file in src/pages/ becomes one page.
| URL | File | What's on it |
|---|---|---|
/ |
src/pages/index.astro |
Hero, mission, count-up impact numbers, programs, story spotlight, appeal progress bar, partners, newsletter |
/about |
src/pages/about.astro |
Mission and vision, history timeline, leadership and board, donut chart of spending |
/programs |
src/pages/programs.astro |
Region filter, dot-matrix world map that highlights regions, six program cards |
/stories |
src/pages/stories.astro |
Featured story and a grid of story cards |
/story |
src/pages/story.astro |
One long-form story with pull quote, figure and copy-link sharing |
/get-involved |
src/pages/get-involved.astro |
Volunteer roles with "Apply for this role", application form, fundraising ideas, corporate partnerships |
/events |
src/pages/events.astro |
Upcoming events with RSVP buttons, RSVP form with a guest stepper |
/donate |
src/pages/donate.astro |
One-time or monthly, preset and custom amounts, impact message, cover the fees, dedication, demo card checkout, donor FAQ |
| 404 | src/pages/404.astro |
Not-found page (most hosts show 404.html automatically) |
Project structure#
nonprofit/
├─ src/
│ ├─ pages/ one .astro file per page (see above)
│ ├─ layouts/
│ │ └─ Layout.astro <head> (title, meta, Open Graph, fonts), skip link, header, footer, scripts
│ ├─ components/
│ │ ├─ SiteHeader.astro logo, desktop navigation, Donate button, mobile menu
│ │ └─ SiteFooter.astro footer links, newsletter form, registration line
│ ├─ data/
│ │ └─ giving.ts donation amounts, impact messages, matched appeals, program regions
│ ├─ lib/
│ │ └─ site.ts site name, theme colour and the url() helper for links
│ ├─ scripts/
│ │ ├─ main.ts all interactivity (menu, counters, programs map, forms, donate flow)
│ │ └─ back-to-top.ts floating "Back to top" button
│ └─ styles/
│ └─ global.css Tailwind theme (colours, fonts) and shared component classes
├─ public/img/ SVG illustrations, story images, favicon, og-image.png (and its SVG source)
├─ astro.config.mjs site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.jsonPages are rendered to HTML at build time. The two files in src/scripts/ are the only JavaScript sent to
the browser, plus one inline line in the layout that swaps the no-js class on <html> for js before
the page paints. Layout.astro imports both scripts, and Astro bundles them into one small module.
Customising#
Brand and colours#
The palette and fonts are tokens in the @theme block at the top of src/styles/global.css:
| Token | Used for |
|---|---|
--color-cream, --color-sand |
Page background and soft panels |
--color-ink, --color-muted, --color-line |
Text, secondary text and borders |
--color-sea-50 ... --color-sea-900 |
The main teal brand scale (headings, buttons, footer) |
--color-sun-100 ... --color-sun-600 |
Donate buttons, highlights and focus rings |
--color-clay-400 ... --color-clay-600 |
Warm accents (map pins, hearts, demo notes) |
To re-brand, change the sea scale; pnpm dev picks it up instantly:
@theme {
--color-sea-500: #2563eb;
--color-sea-600: #1d4ed8;
--color-sea-700: #1e40af;
}The donut chart in src/pages/about.astro uses literal colours (stroke="#1a8a81" and so on); update
them by hand. The browser theme colour is site.themeColor in src/lib/site.ts (#0d5955).
Shared component classes (.wrap, .btn, .btn-donate, .card, .field, .field-error, .amount,
.progress, .reveal and others) are defined in src/styles/global.css.
Typography#
Headings use Fraunces (--font-display) and text uses DM Sans (--font-sans), loaded from
Google Fonts by a <link> in src/layouts/Layout.astro. To change them, replace that link and the two
tokens in global.css. To self-host, put the font files in public/fonts/, add @font-face rules to
global.css and remove the <link> and the two preconnect tags.
Content#
-
Page text lives in each
src/pages/*.astrofile. The page title and description are thetitleanddescriptionprops on<Layout>at the top:html<Layout title="Donate — Clearwater Trust" description="..." current="donate" > -
Impact numbers on the home page:
<span data-count="1240000">1,240,000</span>counts up to the value ofdata-count. Keep the text the same as the attribute, since it's what shows without JavaScript. -
Appeal progress on the home page: the block with
data-campaign data-raised="528400" data-goal="750000"is the only place to change. The script writes the raised amount ([data-raised-text]) and goal ([data-goal-text]), fills the bar ([data-progress]) and sets the progress bar'saria-valuenow. Keep the printed numbers roughly right, since they show without JavaScript. -
Donation amounts, impact messages, matched appeals and program regions are typed data in
src/data/giving.ts(see Donate page and Programs map and region filter). -
Spending chart in
src/pages/about.astro: each slice is a<circle>withstroke-dasharray="<length> <rest>"on a circle of circumference 439.82 (radius 70). A slice's length ispercent × 439.82 − 2(the 2 leaves a gap), its rest is439.82 − length, and itsstroke-dashoffsetis minus the total of the previous slices'percent × 439.82. Update the<title>texts and the "84¢" label with it. -
Volunteer roles and events: see Volunteer roles and event RSVPs.
-
Stories: every card on
/storieslinks to the one example/story. Copysrc/pages/story.astroto a new file (for examplesrc/pages/rain-in-the-hills.astro) for each story, give it its owntitleanddescription, and point the cards at it withhref={url("/rain-in-the-hills")}.
Images and artwork#
All artwork is SVG in public/img/: page illustrations (hero.svg, about.svg, donate.svg,
events.svg, involved.svg, cta.svg, lost.svg) and story images (story-*.svg). Replace them with
photos of the same proportions, keep the alt text meaningful, and link them with the url() helper so
they work under a base path:
<img src={url("/img/hero.jpg")} alt="Women collecting water at a new tap stand" width="1200" height="900" />Leadership portraits in about.astro are inline SVG. The favicon is public/img/favicon.svg; the social
image is public/img/og-image.png (1200×630), exported from public/img/og-image.svg. For automatic
resizing and WebP output, put images in src/ and use Astro's <Image /> from astro:assets instead.
Navigation, header and footer#
src/components/SiteHeader.astrois the header: logo, desktop links, the Donate button and the mobile menu (#mobile-menu). A link getsaria-current="page"(and its highlighted style) when the page'scurrentprop matches it, for examplecurrent="programs".story.astropassescurrent="stories"withcurrentType="true", so the Stories link stays highlighted and is markedaria-current="true"(the current section, not the current page).src/components/SiteFooter.astrois the footer, with the newsletter form and the placeholder registration line ("EIN 00-0000000"); replace it with your real details.
To add a page, create src/pages/annual-report.astro:
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="Annual report — Clearwater Trust" description="Our year in numbers." current="annual-report">
<main id="main">
<section class="wrap py-20">
<h1 class="font-display text-5xl font-semibold">Annual report</h1>
</section>
</main>
</Layout>Each page provides its own <main id="main"> (the skip link's target). Then add the link to the desktop
and mobile menus in SiteHeader.astro (with the same aria-current={(current === "annual-report") ? currentType : undefined}
pattern) and to the footer. Always write internal links as href={url("/annual-report")} so they keep
working under a base path.
Features#
All interactive code is in src/scripts/main.ts, one commented block per feature. Each block looks for
its elements by id or data-* attribute and does nothing if they're missing.
Count-up numbers and appeal progress#
Elements with data-count count up (1.6 s, eased) and appeal blocks with data-campaign fill when they
scroll into view (once() with an IntersectionObserver). With reduced motion the final number is shown
immediately.
Programs map and region filter#
On /programs, the chips with data-filter (all, east-africa, southern-africa, south-asia,
latin-america) show matching program cards (<article data-region="..."> inside [data-programs]),
highlight that region's dots and pin on the map ([data-map]), and update [data-region-title],
[data-region-text] and [data-program-count]. Clicking a map pin filters too. The region is kept in
the URL hash, so /programs#east-africa links straight to it; an unknown hash shows all regions.
The region names and summaries are REGIONS in src/data/giving.ts:
export const REGIONS: Record<string, [title: string, text: string]> = {
all: ["All regions", "1.24 million people across 9 countries"],
"east-africa": ["East Africa", "Kenya, Uganda and Ethiopia · 612,000 people"],
// ...
};To add a region, add an entry, a chip in programs.astro, data-region on the cards, and on the map
give the country dots class="region" data-region="<id>" and add a <g class="pin" data-region="<id>">.
Donate page#
The donate form ([data-donate] in src/pages/donate.astro) keeps a small state of frequency and
amount:
- Give once / Monthly buttons (
data-frequency) swap the preset amounts and set a default ($50 once, $25 monthly). - Preset amounts come from
AMOUNTS; the Other amount field (#d-custom) has a $5 minimum. - Impact message:
IMPACTis checked from the largest amount down, and the first row the amount reaches is shown. Monthly gifts also show the yearly total. - Cover the fees (
#d-fees) adds 3% to the total. The summary and the submit button ([data-submit-label]) update live. - Dedication (
#d-dedicate) reveals "In honour of / In memory of", a name (then required) and an optional email. /donate?appeal=<id>(used by the appeal's "Double my gift" button) shows the matched-giving note ([data-appeal-note]) only when<id>is listed inMATCHED_APPEALS(default["dry-season"]).
All of these settings are in src/data/giving.ts:
export const AMOUNTS: Record<Frequency, number[]> = {
once: [25, 50, 100, 250, 500],
monthly: [10, 25, 50, 100, 200],
};
export const IMPACT: [min: number, headline: string, detail: string][] = [
[500, "repairs a broken well serving 300 people.", "Parts, a trained mechanic and a water test, ..."],
// ... down to
[0, "helps bring water closer to home.", "Every gift, of any size, is pooled to fund projects ..."],
];Keep the [0, ...] row last so every amount has a message. The demo card fields format themselves and
check the number (Luhn; try 4242 4242 4242 4242), a future MM / YY expiry and a 3–4 digit CVC. The
amount buttons have aria-pressed, and the thank-you message receives focus.
Volunteer roles and event RSVPs#
On /get-involved, each "Apply for this role" button (data-apply="Event volunteer") selects that role
in the application form (#v-role) and scrolls to it. The value must match an <option> in #v-role.
On /events, each RSVP button (data-rsvp="run") selects the event in #r-event (by option value) and
focuses the name field. The guests field is a stepper ([data-stepper] with data-step="-1" / "1"
buttons) limited by the input's min and max (1–10).
Copy link#
On /story, the Copy link button (data-copy-link) copies the page address with the Clipboard API
and reports the result in [data-copy-status] (a live region), clearing it after 2.5 seconds.
Scroll reveals#
Elements with reveal fade up as they scroll into view (stagger with style="--d:90ms"). Jumping down
the page reveals everything above too. The hiding styles only apply once the inline script has replaced
no-js with js on <html>, so content is visible without JavaScript, and everything shows at once
with reduced motion.
Mobile menu#
The menu button (data-menu-toggle) opens #mobile-menu, updates aria-expanded and its label, swaps
the [data-open] / [data-close] icons, closes with Esc (returning focus to the button) and closes
itself when the window reaches the lg breakpoint. The header gets an is-scrolled shadow once the page
scrolls.
Back to top button#
src/scripts/back-to-top.ts adds a .back-to-top button that appears after you scroll down about one
screen. It scrolls back up (instantly with reduced motion) and moves keyboard focus to #main. It hides
while an in-page "Back to top" link is on screen. Mark your own fixed UI with data-back-to-top-avoid
(lift above it) or data-back-to-top-hide (hide while covering it). To remove it, delete the file, its
import in Layout.astro and the .back-to-top rules at the end of global.css.
Forms#
| Form | Page | Fields |
|---|---|---|
| Newsletter | / |
name, email |
| Footer newsletter | every page | email |
Volunteer application (#apply) |
/get-involved |
name, email, role, availability (checkboxes), message (20+ characters), consent |
RSVP (#rsvp) |
/events |
event, name, email, guests |
Donation (#donate-form) |
/donate |
custom, fees, dedicate, dedication_type, dedication_name, dedication_email, first_name, last_name, email, demo card, exp, cvc |
Forms with data-validate check required fields, email format, minimum lengths and ranges. Errors appear
under each field with aria-invalid and aria-describedby, and the first problem is focused; after
that, fields re-check as you type. On success the form's [data-success] message shows the text from
data-success-text (for example data-success-text="Thanks for subscribing!") and the form resets.
The demo forms don't send anything. To connect one to Formspree, add the
endpoint as the form's action in its .astro file:
<form action="https://formspree.io/f/your-id" method="POST" novalidate data-validate data-success-text="Thanks!">then, in the "Generic form validation" block of src/scripts/main.ts, replace the demo success code
under the comment "To send for real" with:
const res = await fetch(form.action, {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (!res.ok) return; // show an error message here
const ok = $("[data-success]", form);
ok.textContent = form.dataset.successText || "Thank you!";
ok.classList.remove("hidden");
form.reset();and make the submit listener async (e) => { ... }. To run your own server code instead, add an Astro
adapter (for example @astrojs/node, @astrojs/netlify or @astrojs/vercel) and an
API endpoint such as src/pages/api/rsvp.ts with
export const prerender = false, then post to it.
Donations: never collect card numbers on your own page. The card fields are a demo. Replace that part
with your payment provider's hosted checkout (Stripe Checkout, PayPal Giving, Donorbox...): at the comment
"Demo only: replace this with a call to your payment provider" in the "Donate form" block, send the
amount (state.amount), frequency (state.freq) and fee choice (fees.checked) to the provider and
redirect there, and remove the card fields from donate.astro.
SEO and sharing#
-
Each page passes
titleanddescriptionto<Layout>. The layout turns them into<title>, the meta description,og:title,og:descriptionand asummary_large_imageTwitter card. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://nonprofit.example.com). Set it to your domain when you build:shSITE_URL=https://example.org pnpm buildCanonical URLs have no
.html(https://example.org/donate). -
The 404 page passes
noindex, which adds<meta name="robots" content="noindex">and leaves out the canonical URL. -
For a sitemap, add
@astrojs/sitemap(pnpm astro add sitemap) and apublic/robots.txtthat points to it. Consider NGO structured data as JSON-LD inindex.astro, and give each copied story page its own title and description.
Accessibility#
Built in:
- A "Skip to content" link to
#main, landmarks, one<h1>per page and visible focus rings. - Text colours meet WCAG AA contrast.
- Filter chips, frequency and amount buttons use
aria-pressed; the map has a text title and summary that update with the filter. - The donut chart has a full text
<title>with every percentage. - Form errors are linked to their fields; success messages use
role="status"; the donation thank-you receives focus. - Reveals, counters and smooth scrolling respect
prefers-reduced-motion.
When editing, keep alt text meaningful, label new form fields, and keep the summaries in REGIONS in
sync with the cards.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as donate.html rather than
donate/index.html (build.format: "file" in astro.config.mjs), and links point to /donate without
a trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /donate from donate.html
automatically; Vercel does with cleanUrls (below).
- Netlify / Cloudflare Pages: build command
pnpm build, publish directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/donateservesdonate.html. - GitHub Pages: use the official Astro GitHub Action.
For a project site (
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/donatetodonate.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g.
DEMO_BASE_PATH=/charity pnpm build. It becomes Astro's base, and every link written with url()
includes it.
demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.
TypeScript 7 setup#
package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and
provides the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the
TypeScript 6 API that astro check and typescript-eslint load. When those tools support TypeScript 7,
replace both with a single typescript@7. tsconfig.json extends Astro's strict preset.
Troubleshooting#
- The appeal amount or bar is wrong. Check
data-raisedanddata-goalon the[data-campaign]block inindex.astro; they drive both the printed figures and the bar. - "Apply for this role" leaves the role empty. The button's
data-applymust match an<option>in#v-roleexactly. - An RSVP button doesn't select the event.
data-rsvpmust match an optionvaluein#r-event. - A program never shows for a region. Its card's
data-regionmust match a key inREGIONS. - Links or images break when served from a sub-folder. Build with
DEMO_BASE_PATH, and write internal links and asset paths withurl()fromsrc/lib/site.ts. /donateshows a 404 on my host. The host doesn't map extensionless URLs to.htmlfiles. Setbuild.format: "directory"inastro.config.mjsand rebuild.- Forms "send" but nothing arrives. Connect them as described in Forms.
pnpm installskips a build script.pnpm-workspace.yamllists the packages allowed to run install scripts (esbuild,sharp,@tailwindcss/oxide); add any new one there.
License#
MIT. The code and all included SVG artwork are free for personal and commercial use; the download
includes the license text in LICENSE.txt. Fraunces and DM Sans are licensed under the SIL Open Font
License. Clearwater Trust, its people and figures are fictional.