Freeframe

Documentation

Lingua

v1.0.1Updated Oct 17, 2026A bright, friendly website for a language school with in-person and online classes.

Overview#

Lingua is a complete website for a language school that teaches adults in small groups, shown as Lingua Language School, a fictional school at 14 Harbour Lane in Bristol with in-person and live online classes in Spanish, French, Italian, German, Japanese and Portuguese. It has a course catalogue with filters, course pages with tabs, an interactive placement test that recommends a course, a weekly timetable, a teacher directory, fees in tabs with a four-step enrolment form, an about page and a contact page. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.0. It builds to static HTML files with no UI framework and one small script, and behaves the same as the HTML version.

Live demo: freeframe.dev/templates/lingua

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/lingua into demo/ (only used for the demo on freeframe.dev)

Pages#

Every file in src/pages/ becomes one page.

URL File Notable sections and features
/ src/pages/index.astro Hero with greeting bubbles, six language cards, how it works, in person and online, three courses starting soon, student quotes, teachers, placement test call to action
/courses src/pages/courses.astro All 13 courses with filters by language, level and format (kept in the URL), and the CEFR levels explained
/course src/pages/course.astro One course chosen with ?id=<course id>: key facts, tabs for overview, syllabus and teacher, fee card, related courses
/placement src/pages/placement.astro Placement test: a language, ten questions, then an estimated level and recommended courses
/teachers src/pages/teachers.astro Eight teachers with a language filter and the courses each one teaches
/timetable src/pages/timetable.astro A week of classes (Monday to Saturday) with filters by language, format and time of day
/enrol src/pages/enrol.astro Fees in tabs (group courses, private lessons, companies) and the four-step enrolment form with a live summary
/about src/pages/about.astro Story and figures, how we teach, the building, FAQ
/contact src/pages/contact.astro Contact form (topic prefilled from the URL), map, address, reception hours, getting here
404 src/pages/404.astro Friendly 404 page (most hosts show 404.html automatically)

Project structure#

lingua/
├─ src/
│  ├─ pages/                 one .astro file per page (see above)
│  ├─ layouts/
│  │  └─ Layout.astro        <head> (title, meta, Open Graph, fonts), header, <main>, footer, scripts
│  ├─ components/
│  │  ├─ SiteHeader.astro    skip link, logo, desktop and mobile navigation, "Enrol now" button
│  │  ├─ SiteFooter.astro    address, footer links, newsletter form
│  │  └─ CourseCard.astro    the course card used on the home, courses and course pages
│  ├─ data/
│  │  ├─ school.ts           languages, levels, courses, teachers, syllabus, outcomes, placement questions, fees
│  │  └─ fees.ts             the plans shown in the fees tabs
│  ├─ lib/
│  │  ├─ site.ts             site name, theme colour and the url() helper for links
│  │  ├─ format.ts           dates, schedules, places left, related courses and syllabus weeks
│  │  └─ icons.ts            the inline SVG icons
│  ├─ scripts/
│  │  ├─ main.ts             all interactivity: filters, course page, tabs, placement test, enrolment, forms
│  │  └─ back-to-top.ts      floating "Back to top" button
│  └─ styles/
│     └─ global.css          Tailwind theme (@theme tokens) and component classes
├─ public/img/               SVG artwork, teachers/ portraits, logo.svg, favicon.svg, og-image.svg and og-image.png
├─ 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 from src/data/. The two files in src/scripts/ are the only JavaScript sent to the browser, plus one inline line in the layout that adds a js class to <html>. Layout.astro imports both scripts, and Astro bundles them (with the data and helpers they use) into one small module.

Customising#

Brand and colours#

The design tokens are in the @theme block at the top of src/styles/global.css:

Token Default Used for
--color-paper #fbf8f1 Page background
--color-paper-2 #f3ecdd Panels and page headers
--color-line #ddd2bd Borders
--color-ink #1c1f3a Text, outlines, card shadows, dark sections
--color-muted #555a72 Secondary text
--color-brand / --color-brand-dark #2f3fb0 / #222f8c Primary buttons, links, icons
--color-brand-soft #e4e7fb Highlighted cards and chosen options
--color-tomato / --color-tomato-ink #e25c3c / #ae391d Decoration / accent text (eyebrows, "places left")
--color-sun #f6bf3f Level badges, highlights, buttons on dark backgrounds
--color-mint #bfe5d1 Success panels
--color-es, -es-ink, -es-solid (and fr, it, de, ja, pt) One colour set per language: chips, timetable cards, greeting bubbles

Change a value and every utility that uses it follows (bg-brand, text-tomato-ink...). For example, a green brand:

css
@theme {
  --color-brand: #1f7a52;
  --color-brand-dark: #165c3d;
  --color-brand-soft: #dcf0e5;
}

The "sticker" look comes from the 2px ink borders and solid offset shadows on .card, .bubble and the btn utility; soften it by changing the shadow-[…] values there. Each language's colour pair is applied with a class: tone-es, tone-fr, tone-it, tone-de, tone-ja or tone-pt (the tone field of each language). Keep the -ink colours dark enough for text on their light background (WCAG AA). The browser's theme colour is site.themeColor in src/lib/site.ts.

Typography#

Two Google Fonts are loaded with one <link> in src/layouts/Layout.astro: Young Serif for headings (font-display) and Lexend for text (font-sans, weights 300–700). To swap them, change that link and the two font tokens in global.css:

css
--font-sans: "Lexend", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: "Young Serif", ui-serif, Georgia, "Times New Roman", serif;

To self-host instead, put the font files in public/fonts/, add @font-face rules to global.css and remove the Google Fonts link and its two preconnect tags.

Content#

Text that appears once is written in the page's .astro file. Everything that repeats, and everything the script needs, lives in src/data/school.ts:

Export What it holds
LANGS The six languages: id, two-letter code, name, hello, colour tone and a blurb for the home page
LEVELS CEFR levels A1 to C1 with a name and a one-line description
KEEP_DEMO_CURRENT, TERM true and { starts: "2027-01-11", ends: "2027-03-26" }: the term the courses belong to. Once it has started, the script moves the term and every course start on by whole weeks to the same term next year (see below). Set it to false once COURSES and TERM hold your real dates
COURSES Every course (see below)
TEACHERS Name, role, languages, home town, years teaching, bio and a quote; portraits are public/img/teachers/<id>.svg
SYLLABUS, OUTCOMES Five modules and four "you'll be able to" points per level, shared by every language
PLACEMENT, ANSWERS The placement test's ten statements and three answers
BOOK_PRICE, CONCESSION The course book price (£) and the concession discount (0.15 = 15%)

A course:

ts
{ id: "fr-a1-evening", lang: "french", level: "a1", format: "in-person", title: "French for Beginners",
  days: [0, 2], start: "18:30", end: "20:00", weeks: 10, starts: "2027-01-11", fee: 295,
  teacher: "julien-mbaye", seats: 5, room: "Room 3",
  summary: "Start speaking French with confidence, two evenings a week.",
  description: "Our most popular course. …" }

days are 0 (Monday) to 5 (Saturday); starts is the first class as YYYY-MM-DD; fee is in pounds per term; seats is the number of places left (three or fewer shows "Only 3 places left" in the accent colour); format is in-person or online (online courses show "Live online" and use room: "Online"). To add a course, add an object with a new lower-case id: it appears on the courses page, the timetable, the enrolment form's course list, the teacher's card, the placement test's recommendations and at /course?id=<id>. The home page's three featured courses are listed by id in src/pages/index.astro.

Demo dates. The demo is written for the winter term in TERM (11 January to 26 March 2027). While KEEP_DEMO_CURRENT is true and that term has started, the script moves the term and every course's starts forward to the same term the next year, by whole weeks so weekdays never change (11 January 2027 becomes 10 January 2028), so visitors never see a course that has already begun (src/lib/term.ts). It also updates the dates built into the pages: data-term="year", "starts" and "ends" (the courses and timetable headers) and data-course-from on each course card. Set KEEP_DEMO_CURRENT = false when you enter your real term dates.

The fees tabs read PRICE_TABS in src/data/fees.ts (three plans per tab, each with a href written as a site path such as /contact?topic=private). Dates and labels shown on cards (11 Jan, Mon & Wed, 18:30–20:00, 30 hours) come from the helpers in src/lib/format.ts; they format date strings without Date objects, so the output never depends on the visitor's time zone.

Images and artwork#

All artwork is SVG in public/img/, drawn for this template: hero-class.svg (1200 × 1000, shown at 6:5), in-person.svg and online.svg (1200 × 800, 3:2), school.svg (1200 × 900, 4:3), map.svg (1200 × 900, 4:3) and the eight teacher portraits in teachers/ (600 × 500, 6:5). Images sit in .art boxes that crop with object-fit: cover, so photos of a similar shape drop in: change the src (and alt) in the page, or the file name. For a teacher, replace public/img/teachers/<id>.svg or point the pages at a JPEG.

logo.svg (the two speech bubbles) is used in the header and footer, favicon.svg is the browser icon, and og-image.png (1200 × 630) is the social sharing image; og-image.svg is its editable source. Re-export the PNG after editing, since most social sites don't show SVG.

The menus are the NAV (desktop) and MOBILE (adds Home and Fees & enrolment) arrays at the top of src/components/SiteHeader.astro. Each page passes current to <Layout> (current="courses"), which marks the matching link with aria-current="page". To add a page, create src/pages/<name>.astro using <Layout>, then add an entry to both arrays. The footer's links, address and newsletter form are in src/components/SiteFooter.astro.

Features#

Course filters#

/courses filters the cards by language, level and format (initCourseFilters() in main.ts). Each card carries data-lang, data-level and data-format; the selects are marked data-filter="lang|level|format". The count ([data-course-count]) is a polite live region, and an empty state appears when nothing matches. Choices are written to the URL with history.replaceState (/courses?lang=french&format=online) and read back on load, so filtered views can be shared; the home page's language cards link to /courses?lang=<id>. Parameter names and values are read in any case, and only values that exist in the selects are accepted.

Course page#

/course?id=<id> shows one course. The page is built with the first course in COURSES; initCoursePage() then fills every [data-course-field] element, the outcomes, syllabus and teacher tabs, the enrol and placement links and three related courses (the same language first, then the same level) from the course named in the URL. The id is case-insensitive; unknown ids show the first course. To give each course its own indexable URL, generate pages with getStaticPaths() from COURSES.

Tabs#

The course page (Overview, Syllabus, Your teacher) and the fees (Group courses, Private lessons, For companies) use the ARIA tabs pattern (initTabs()): role="tablist", role="tab" with aria-selected and aria-controls, and role="tabpanel". Arrow keys move between tabs (wrapping), Home and End jump to the first and last, and only the selected tab is in the tab order. A hash that names a panel opens it: /enrol#private opens the panel with id panel-private and scrolls to the tabs (smoothly unless the visitor prefers reduced motion).

Placement test#

/placement (initPlacement()) asks for a language, then shows the ten PLACEMENT statements one at a time, each answered "Not yet" (0), "With some effort" (1) or "Easily" (2). A progress bar and "Step 3 of 11" show where you are; Next without an answer shows an inline error, and focus moves to each new question's heading. The total score (0–20) gives the level with PLACEMENT_BANDS = [5, 9, 13, 17] in main.ts: under 5 is A1, 5–8 A2, 9–12 B1, 13–16 B2 and 17–20 C1. recommend() then lists up to two courses in that language at that level, or the nearest level (the lower one when two are equally close), with a note when there's no exact match.

The result is saved (see Saved data) and shown again on the next visit, with "Start again" to clear it. /placement?lang=french starts a new test with French chosen (any case; only the six language ids). The course page's "Take the placement test" link uses it.

Teacher filter#

/teachers has a row of toggle buttons (role="group", aria-pressed) that show the teachers of one language (initTeacherFilter(), reading each card's data-langs). /teachers?lang=japanese opens filtered; unknown values show everyone.

Timetable#

/timetable lists every weekly class in six day columns, built from COURSES (a course with two days appears on both). Filters for language, format and time of day (initTimetable()) hide classes; a day with none left shows "No classes" and an empty state appears when nothing matches. Times of day come from timeOfDay() in format.ts: mornings start before 12:00, lunchtimes before 17:00, evenings after; "Saturdays" picks the Saturday column. Filters are kept in the URL (?lang=&format=&time=). Each class links to its course page.

Enrolment form#

The enrolment form on /enrol (initEnrol()) has four steps with an indicator (aria-current="step"):

  1. Course: a select grouped by language, with when, where, teacher and places left below it. Required.
  2. Your details: first and last name and email (required), phone (optional, at least seven digits), and how much they've studied the language before (required).
  3. Options: pay in full or in three instalments, the concession rate and the course book.
  4. Review: everything chosen, the total, and the terms checkbox (required).

Continue validates the step, shows errors under each field and focuses the first problem; on success focus moves to the next step's heading. The summary beside the form updates live: course fee, concession (15% off the course fee), course book, total and, for instalments, the three payments (enrolQuote() works in pence, so the three payments always add up to the total). /enrol?course=<id> (any case) chooses the course; the course page and the placement result link to it with #enrol. On confirmation the form is replaced by a thank-you panel that says nothing was sent, and the draft is cleared.

Contact topic prefill#

/contact?topic=private chooses "Private lessons" in the topic select (initContactPrefill()). Any case works; only the select's own values (general, courses, placement, private, companies) are accepted.

Header, mobile menu and scroll reveals#

The header gets a border once the page scrolls (is-scrolled). Below 1024px the menu button (#menu-toggle) opens #mobile-menu: it updates aria-expanded and its label, locks page scrolling, and closes with Escape (returning focus to the button) or when the window grows past the breakpoint. Elements with the reveal class fade in as they scroll into view; everything above a revealed element is revealed too, so jumping to the end never leaves hidden content. The home page's greeting bubbles bob gently. With reduced motion, reveals, the bobbing and smooth scrolling are off.

Back to top#

src/scripts/back-to-top.ts adds a round button after about one screen of scrolling. It scrolls to the top (instantly with reduced motion) and moves focus to <main>. It hides while it would cover a sticky element marked data-back-to-top-hide (the course fee card and enrolment summary). To remove it, delete the file, its import in Layout.astro and the .back-to-top styles in global.css.

Forms#

Form Page Validates
Enrolment (data-enrol-form) /enrol Each step (see above)
Placement test (data-placement-form) /placement An answer on every step
Contact (data-form="contact") /contact Name, email, message of at least 20 characters
Newsletter (data-form="newsletter") Footer of every page Email

Every form[data-form] is handled by initForms(): errors appear under each field (in the element named by aria-describedby), focus moves to the first invalid field, and a success message ([data-form-success]) is shown. Demo forms don't send anything until you connect them.

To send them with a form service such as Formspree, replace the comment in the submit handler of initForms():

ts
// To send for real: fetch('https://formspree.io/f/your-id', { method: 'POST', body: new FormData(form) })
fetch("https://formspree.io/f/your-id", { method: "POST", body: new FormData(form), headers: { Accept: "application/json" } })
  .then((res) => {
    if (!res.ok) throw new Error("Request failed");
    form.reset();
    success?.classList.remove("hidden");
    success?.focus();
  })
  .catch(() => alert("Sorry, your message could not be sent. Please try again or email us."));

and remove the lines after it that reset the form and show the success message straight away. Update the success messages in the pages, which currently say the form is a demo. To use different endpoints per form, branch on form.dataset.form (contact, newsletter).

The enrolment form is sent the same way: in initEnrol()'s submit handler, at the comment "To send for real", post new FormData(form) (or JSON.stringify(draft) with the quote from enrolQuote(draft)) to your booking system and show the thank-you panel when it succeeds. Take payment on your server or with a payment provider, never in the page.

To run your own server code instead, add an Astro adapter (@astrojs/node, @astrojs/netlify or @astrojs/vercel) and an API endpoint such as src/pages/api/enrol.ts with export const prerender = false, then post to it.

Saved data (localStorage)#

Key Shape
lingua:enrol:v1 { course, first, last, email, phone, experience, plan, concession, book, heard }: the enrolment in progress
lingua:placement:v1 { lang, answers }: the last placement result (answers is ten numbers, 0 to 2)

Both are checked on every read:

  • the enrolment draft goes through sanitizeDraft(): course must be a course id, names, email and phone are strings with control characters removed and a maximum length, experience, plan and heard must be one of the form's own values, and concession and book must be true to count. Anything else is dropped and the repaired draft is written back. A ?course= in the URL takes priority over the draft.
  • a placement result must name one of the six languages and have exactly ten answers of 0, 1 or 2; otherwise it is removed and the test starts again.

JSON that can't be parsed is removed, so a corrupt or edited value never breaks a page, and user text is always escaped when shown. To reset by hand, run localStorage.removeItem("lingua:enrol:v1") (or the placement key) in the browser console. window.Lingua exposes enrolQuote, sanitizeDraft, placementLevel and recommend for tests and debugging; remove that line at the end of main.ts if you don't want it.

SEO and sharing#

  • Each page passes title and description to <Layout>, which writes <title>, the meta description, og:title, og:description, a summary_large_image Twitter card and a theme-color. The course page also updates the document title for the course in the URL.

  • 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://lingua.example.com). Set it to your domain when you build:

    sh
    SITE_URL=https://www.your-school.com pnpm build
  • 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. Consider Course and EducationalOrganization structured data (JSON-LD) with your address and courses.

Accessibility#

Built in:

  • a "Skip to content" link and landmarks (header, nav, main, footer) on every page;
  • visible focus rings (:focus-visible in global.css), including on radio and checkbox cards;
  • one h1 per page;
  • tabs, toggle buttons and step indicators using the standard ARIA patterns (role="tab", aria-pressed, aria-current="step");
  • radio groups in fieldsets with legends; form errors linked with aria-describedby and aria-invalid, and focus moved to the first error or to the next step's heading;
  • live regions for filter counts and the fee summary;
  • reduced motion respected by reveals, the greeting bubbles, smooth scrolling and the back-to-top button;
  • text colours that meet WCAG AA contrast, including each language's chip colours.

When editing, keep one h1 per page, give every new input a <label>, keep alt text meaningful, and check contrast if you change colours.

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 courses.html rather than courses/index.html (build.format: "file" in astro.config.mjs), and links point to /courses without a trailing slash. Tailwind scans src/, including the class names written in main.ts.

  • 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 /courses serves courses.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 /courses to courses.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=/school pnpm build. It becomes Astro's base; links written with url() and the paths the script builds with BASE include 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 course page always shows Spanish for Beginners. The id in the URL must match an id in COURSES; unknown ids fall back to the first course.
  • A new course doesn't appear in the timetable. Check its days (0 = Monday … 5 = Saturday; Sunday isn't shown) and that start and end are HH:MM.
  • The placement test shows an old result. It is saved on the device. Choose "Start again", or clear lingua:placement:v1 (see Saved data).
  • The enrolment form starts with someone else's details. It restores the saved draft on this browser; clear lingua:enrol:v1, or remove the draft code if your site runs on shared computers.
  • Links or images break when served from a sub-folder. Build with DEMO_BASE_PATH, write links and image paths with url() in pages, and with BASE in main.ts.
  • Two words run together after editing. Astro compresses whitespace in markup; when text continues on the next line before an inline element such as <a>, keep them on one line or add {" "}.
  • Social previews show the wrong domain or no image. Set SITE_URL to your domain at build time.

License#

MIT: the full text is in LICENSE.txt, included in the download. You can use, change and sell sites built with this template, including the SVG artwork. Lingua Language School, its teachers and its students are fictional. Young Serif and Lexend are licensed under the SIL Open Font License.

Search documentation

Search the guides and every template's documentation