Freeframe

Documentation

Nonprofit

v1.0.3Updated Oct 9, 2026A warm, trustworthy website for a charity, with a working donate page.

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).

sh
pnpm install
pnpm dev          # http://localhost:4321

With 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.json

Pages 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:

css
@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/*.astro file. The page title and description are the title and description props 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 of data-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's aria-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> with stroke-dasharray="<length> <rest>" on a circle of circumference 439.82 (radius 70). A slice's length is percent × 439.82 − 2 (the 2 leaves a gap), its rest is 439.82 − length, and its stroke-dashoffset is 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 /stories links to the one example /story. Copy src/pages/story.astro to a new file (for example src/pages/rain-in-the-hills.astro) for each story, give it its own title and description, and point the cards at it with href={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:

html
<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.

  • src/components/SiteHeader.astro is the header: logo, desktop links, the Donate button and the mobile menu (#mobile-menu). A link gets aria-current="page" (and its highlighted style) when the page's current prop matches it, for example current="programs". story.astro passes current="stories" with currentType="true", so the Stories link stays highlighted and is marked aria-current="true" (the current section, not the current page).
  • src/components/SiteFooter.astro is 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:

html
---
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:

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>">.

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: IMPACT is 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 in MATCHED_APPEALS (default ["dry-season"]).

All of these settings are in src/data/giving.ts:

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).

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:

html
<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:

ts
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 title and description to <Layout>. The layout turns them into <title>, the meta description, og:title, og:description and a summary_large_image Twitter card.

  • The canonical URL, og:url and the absolute og:image URL are built from site in astro.config.mjs, which reads SITE_URL (default https://nonprofit.example.com). Set it to your domain when you build:

    sh
    SITE_URL=https://example.org pnpm build

    Canonical 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 a public/robots.txt that points to it. Consider NGO structured data as JSON-LD in index.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#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ 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 directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /donate serves donate.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /donate to donate.html, change build.format to "directory" in astro.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-raised and data-goal on the [data-campaign] block in index.astro; they drive both the printed figures and the bar.
  • "Apply for this role" leaves the role empty. The button's data-apply must match an <option> in #v-role exactly.
  • An RSVP button doesn't select the event. data-rsvp must match an option value in #r-event.
  • A program never shows for a region. Its card's data-region must match a key in REGIONS.
  • Links or images break when served from a sub-folder. Build with DEMO_BASE_PATH, and write internal links and asset paths with url() from src/lib/site.ts.
  • /donate shows a 404 on my host. The host doesn't map extensionless URLs to .html files. Set build.format: "directory" in astro.config.mjs and rebuild.
  • Forms "send" but nothing arrives. Connect them as described in Forms.
  • pnpm install skips a build script. pnpm-workspace.yaml lists 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.

Search documentation

Search the guides and every template's documentation