Freeframe

Documentation

Nonprofit

v1.0.1Updated Nov 11, 2026A warm, trustworthy website for a charity, with a working donate page.

Overview#

Nonprofit is a complete website for a charity. The fictional Clearwater Trust is a clean-water organization based on Washington Avenue in Minneapolis that funds community-run wells, springs and school water systems in nine countries. The site tells the story with count-up impact numbers, an appeal progress bar and long-form field stories, shows where every dollar goes, and has volunteering, events with RSVPs and a full donation flow. This is the Nuxt version (Nuxt 4, Vue 3, TypeScript, Tailwind CSS v4), version 1.0.1, ported page for page from the HTML template, and it generates to static files. The template page, with a link to the live demo, is at freeframe.dev/templates/nonprofit.

The look is warm and calm: cream paper, deep sea-green panels, a sunny yellow for the Donate buttons, Fraunces for headings and DM Sans for text, with drawn illustrations. It is a light-only design.

The site is a front end only: the forms check what is entered and show a confirmation, but nothing is sent and no payment is taken until you connect them (see Forms).

Quick start#

Requirements: Node.js 24 or newer and pnpm (npm works too).

sh
pnpm install      # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev          # http://localhost:3000

Other scripts:

Script What it does
pnpm generate Static site in .output/public/ (nuxt generate): every page prerendered to HTML
pnpm build A Node.js server build in .output/ (nuxt build), for hosts that run Nuxt as a server
pnpm preview Serve the last build locally
pnpm lint ESLint (@nuxt/eslint)
pnpm typecheck nuxt prepare and vue-tsc
pnpm build:demo Generate for freeframe.dev under /demos/nuxt/nonprofit into demo/

Pages#

Route File Purpose and features
/ app/pages/index.vue Hero with donors, four count-up impact numbers, mission checklist, three ways to help, a story spotlight, the Dry Season Appeal with its progress bar, partner logos, newsletter sign-up
/about app/pages/about.vue Mission and vision, history timeline (2009 to 2026), leadership and board, "Where your money goes" with a donut chart (#finances)
/programs app/pages/programs.vue Region filter, dot-matrix world map and six program cards (app/components/ProgramsExplorer.vue, #east-africa), how every project works
/stories app/pages/stories.vue Featured story and five story cards
/story app/pages/story.vue Long-form story with a drop cap, pull quote, figure, project facts, copy-link sharing and more stories
/get-involved app/pages/get-involved.vue Four volunteer roles and the application form (#apply), fundraising steps (#fundraise), corporate partnerships (#partners)
/events app/pages/events.vue Five upcoming events with RSVP buttons and the RSVP form with a guest stepper (#rsvp)
/donate app/pages/donate.vue The donation form (app/components/DonateForm.vue) and the donor FAQ (#faq)
404 app/error.vue "This path has run dry." with links home and to the stories (generated as 404.html, noindex)

Project structure#

nuxt.config.ts              base path, head defaults, SITE_URL, prerendered routes, Tailwind (@tailwindcss/vite)
app/
  app.vue                   <NuxtLayout> around <NuxtPage>
  layouts/default.vue       skip link, header, the page, footer, back-to-top button
  error.vue                 the 404 page
  router.options.ts         writes query strings the way the HTML version does
  pages/*.vue               one file per page (see Pages)
  components/
    SiteHeader.vue          sticky header, desktop menu, phone menu
    SiteFooter.vue          footer columns, newsletter, copyright year
    LogoMark.vue            the Clearwater Trust mark
    NewsletterForm.vue      the newsletter sign-up (home page card and footer)
    CountUp.vue             a count-up number
    CampaignProgress.vue    the appeal's figures and progress bar
    ProgramsExplorer.vue    region filter, map and program cards
    DonateForm.vue          the donation form
    DonateCta.vue           "Every $50 is a year of clean water" call to action
    FieldError.vue          a form field's error message
    BackToTop.vue           the back-to-top button
  composables/
    useValidation.ts        inline form validation
    useInView.ts            runs something once an element is 40% in view
    useCurrentPage.ts       aria-current for menu links
    usePageSeo.ts           title, description, Open Graph and canonical tags
    useAsset.ts             paths to files in public/ with the base path
    useYear.ts              the copyright year
  data/
    site.ts                 menu, footer links, copyright year
    appeal.ts               the appeal's raised amount, goal and figures
    programs.ts             regions, program cards and their icons
    map.ts                  the dot-matrix map, its region key and pins
    events.ts               upcoming events
    giving.ts               donation amounts, impact messages, fee rate, minimum gift, matched appeals, donor FAQ
  utils/                    money(), makeRef(), prefersReducedMotion()
  plugins/
    reveal.client.ts        scroll reveals
    keep-hash.client.ts     keeps the address's #hash exactly as it was opened
    stable-build.server.ts  reproducible builds
  assets/css/main.css       Tailwind entry, @font-face rules, brand tokens and component styles
  assets/fonts/             Fraunces and DM Sans (woff2) with their licenses
public/img/                 illustrations, favicon.svg, og-image.svg and og-image.png

Customizing#

Brand and colors#

The design tokens are in the @theme block of app/assets/css/main.css:

Token Used for
--color-cream, --color-sand page background and soft panels
--color-ink, --color-muted, --color-line text, secondary text, borders
--color-sea-50 to --color-sea-900 the brand green: headings, panels, buttons, the map
--color-sun-100 to --color-sun-600 the Donate buttons, the progress bar, highlights
--color-clay-400 to --color-clay-600 accents (map pins, the monthly heart)

To change the brand green, edit the --color-sea-* values, for example:

css
@theme {
  --color-sea-600: #1d5f8c;
  --color-sea-700: #174d73;
  --color-sea-800: #123d5c;
  --color-sea-900: #0d2d45;
}

Also update theme-color in nuxt.config.ts. Component styles (.btn-donate, .chip, .amount, .seg, .progress-bar, .map) are in the same file.

Typography#

Fraunces (headings, with its optical-size axis) and DM Sans (text) are self-hosted from app/assets/fonts/ and declared with @font-face at the top of main.css; --font-display and --font-sans in @theme use them. To swap a font, add its woff2 file, change the @font-face rule and the token.

Content#

Most text is in the page files. Lists that drive interactive parts are typed data in app/data/:

  • PROGRAMS in programs.ts: each program has a region (a key of REGIONS), an icon (a key of ICONS), a title, text and a stat.
  • EVENTS in events.ts: id (the RSVP form's option), date (ISO), day, month, format, title, text, when, where and short (the date in the RSVP list).
  • AMOUNTS, DEFAULT_AMOUNT, IMPACT, FEE_RATE, MIN_GIFT, MATCHED_APPEALS and DONOR_FAQ in giving.ts.
  • APPEAL in appeal.ts: the raised amount and goal (the bar fills to raised / goal), donors, days left and the matching funder.

Adding an event:

ts
export const EVENTS: EventItem[] = [
  // ...
  {
    id: "spring-walk",
    date: "2027-04-17",
    day: "17",
    month: "Apr",
    format: "In person",
    title: "Spring Walk for Water",
    text: "Carry a jerrycan for 5 km around Lake Harriet and see what millions walk every day.",
    when: "Apr 17, 9:00 am",
    where: "Lake Harriet, Minneapolis",
    short: "Apr 17",
  },
];

The card and the RSVP form's option appear together. The volunteer roles are written in app/pages/get-involved.vue; a new role needs its card (with apply('<Role>') on its button) and an <option> with the same name in the form.

Images and artwork#

The illustrations in public/img/ are SVG drawings: the hero (hero.svg, 7:5), page headers (about.svg, involved.svg, events.svg, donate.svg, 4:3), stories (story-*.svg, 4:3, and story-amina-2.svg, 12:7), the call to action (cta.svg) and the 404 (lost.svg). Replace them with photos of the same aspect ratio and keep descriptive alt text (or alt="" when decorative). Portraits on the home and about pages are inline SVG in the page files. The favicon is public/img/favicon.svg; the social image is public/img/og-image.png (1200 × 630), drawn from og-image.svg.

The menu is NAV in app/data/site.ts (desktop and phone menus); SECTION marks pages that belong to a menu item (the story page lights up Stories with aria-current="true"). Footer columns are FOOTER_COLUMNS and the bottom links FOOTER_LEGAL. The header and footer markup is in SiteHeader.vue and SiteFooter.vue. To add a page, create app/pages/<name>.vue and add its route to routes in nuxt.config.ts.

Features#

Donation form#

app/components/DonateForm.vue:

  • "Give once" and "Monthly" switch the preset amounts (AMOUNTS) and pick DEFAULT_AMOUNT ($50 once, $25 monthly) unless an "Other amount" is typed.
  • A preset button or "Other amount" sets the gift; the impact message finds the first IMPACT row whose min is at or below the amount ("$100 trains a local pump mechanic for a month."). Monthly gifts add "That's $300 of impact every year."
  • "Add $1.50 to cover processing fees" adds FEE_RATE (3%) to the total; the summary and the button ("Donate $51.50 a month") follow every change.
  • "Dedicate my gift" opens the dedication fields and makes "Their name" required.
  • The card fields format as you type (4242 4242 4242 4242, 12 / 30). On submit the card number is checked with the Luhn algorithm, the expiry must be a valid month that hasn't passed, the CVC 3 or 4 digits and a custom amount at least MIN_GIFT ($5).
  • On success the form shows "Thank you, Ada.", what the gift does and a reference like CT-7K2Q, and the message receives focus.
  • /donate?appeal=dry-season shows the matched-giving note; add appeal ids to MATCHED_APPEALS.

Buttons use aria-pressed, the impact message is a live region and the success message is role="status".

Impact numbers and the appeal#

CountUp.vue counts from zero to its value over 1.6 seconds once the number is 40% in view; the generated HTML and reduced motion show the final number. CampaignProgress.vue fills the bar to raised / goal as it comes into view and sets aria-valuenow; with reduced motion the bar jumps straight there.

Programs filter and map#

ProgramsExplorer.vue shows the programs of one region at a time. The buttons, the map's pins and the address all choose the region: /programs#south-asia opens with South Asia, and every change is written back to the address without adding history entries (the bare path for "All regions"). The map is drawn from MAP_ROWS in app/data/map.ts, one string per row of 60 dots: . is sea, o is land and a letter is a dot in a region (MAP_KEY); MAP_PINS places the pins. The chosen region's dots turn yellow and the others fade. The heading, the region text and "Showing 2 programs" are a live region.

Volunteer roles and events#

"Apply for this role" chooses the role in the application form, scrolls to it and focuses the role field. On the events page, "RSVP" chooses the event, scrolls to the form and puts the cursor in "Full name"; the guest stepper keeps the number between 1 and 10. Scrolling is instant for reduced motion.

Story sharing#

"Copy link" copies the page address to the clipboard and says "Link copied" for 2.5 seconds (or how to copy it when the clipboard isn't available); "Email" opens a prefilled email.

The header gets a stronger background and a shadow after scrolling 10 px. Below 1024 px the menu button opens the phone menu; Escape closes it and returns focus to the button, and following a link or widening the window closes it too. .reveal elements fade up as they scroll into view (app/plugins/reveal.client.ts), with everything above them, and --d sets a delay; reduced motion shows everything at once. The back-to-top button appears after one screen of scrolling and moves focus to the main content.

Keeping the demo current#

The copyright year is written as 2026 in the generated HTML and replaced with the current year once the page loads (useYear()). Event dates, the appeal and the history timeline are fixed content: update EVENTS, APPEAL and the page text as your calendar moves on.

Forms#

All forms validate inline with useValidation() (app/composables/useValidation.ts), using the browser's own rules (required, type="email", minlength, min/max, custom checks): errors appear on submit under each field, the first invalid field is focused, and a field is checked again when you leave it and as you fix it.

Form Where Fields checked
Newsletter (two variants) app/components/NewsletterForm.vue first name (home page card), email
Volunteer application app/pages/get-involved.vue name, email, role, a message of 20 characters or more, consent
RSVP app/pages/events.vue event, name, email, guests (1 to 10)
Donation app/components/DonateForm.vue see Donation form

The demo forms don't send anything until you connect them. Each has a send() function; post the form there, for example to Formspree, before showing the confirmation:

ts
async function send(e: Event) {
  const el = e.currentTarget as HTMLFormElement;
  if (!v.validateForm(el)) return;
  const res = await fetch("https://formspree.io/f/your-form-id", {
    method: "POST",
    body: new FormData(el),
    headers: { Accept: "application/json" },
  });
  if (!res.ok) return;
  sent.value = `Thank you! ... Your reference is ${makeRef()}.`;
  el.reset();
}

For donations, send the gift (amount, freq, fees) to your server and redirect to a payment page such as Stripe Checkout instead of showing the thank-you message, and remove the demo card fields. Never collect card numbers in your own form. With pnpm build you can add a Nuxt server route (server/api/checkout.post.ts) that creates the Checkout session.

SEO and sharing#

Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the title, description, Open Graph and Twitter tags and a canonical link. URLs are absolute: set SITE_URL when you build (SITE_URL=https://your-domain.example pnpm generate); the default is https://clearwatertrust.example. The social image is public/img/og-image.png. The 404 page is noindex. Add a sitemap.xml and robots.txt to public/ when you deploy, and consider NGO structured data with your address and EIN (useHead({ script: [{ type: "application/ld+json", innerHTML: … }] })).

Accessibility#

  • Skip link, landmarks, one <h1> per page and a logical heading order.
  • Visible focus outlines in sun orange.
  • Filter, frequency and amount buttons use aria-pressed; the menu button uses aria-expanded and aria-controls; the dedication checkbox controls its fields with aria-expanded.
  • The region summary and the impact message are live regions; confirmations are role="status".
  • Form errors are linked with aria-describedby and fields get aria-invalid.
  • The map and the spending chart are role="img" with a title; decorative drawings are hidden.
  • prefers-reduced-motion turns off reveals, count-ups and smooth scrolling.

When editing, keep one <h1> per page, label every new input and keep data in app/data/ so the pages and the logic agree.

Building and deploying#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # serve it locally

nuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (donate.html, linked as /donate), plus 404.html from app/error.vue. Next to each page is a folder with its _payload.json, which Nuxt loads when you move between pages. Upload .output/public/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3, Vercel). Hosts that don't map /donate to donate.html on their own need that rewrite (Vercel: "cleanUrls": true); most serve 404.html for unknown URLs.

To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:

sh
DEMO_BASE_PATH=/clearwater pnpm generate

pnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.

TypeScript setup#

pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them). typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint load; replace it with typescript@7 once they support it.

Troubleshooting#

  • A form says "Thank you" but nothing arrives. The forms are not connected; see Forms.
  • The matched-giving note doesn't show. Check that the appeal value in the link is listed in MATCHED_APPEALS.
  • A program doesn't appear under any region. Its region must be a key of REGIONS in app/data/programs.ts.
  • A new page is missing from the generated site. Add its route to routes in nuxt.config.ts.
  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install or npx nuxt prepare first.
  • A Tailwind class written in a template string has no effect. Write class names in full inside quotes; Tailwind can't find classes built from pieces.
  • The illustrations show a different font. They load as <img>, which cannot use web fonts; inline the SVG if you need the page fonts.

License#

Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Fraunces and DM Sans are under the SIL Open Font License 1.1 (app/assets/fonts/*-OFL.txt). Clearwater Trust, its people, partners, places and figures are fictional.

Search documentation

Search the guides and every template's documentation