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).
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/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.jsonPages 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:
@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:
--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:
{ 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.
Navigation, header and footer#
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"):
- Course: a select grouped by language, with when, where, teacher and places left below it. Required.
- 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).
- Options: pay in full or in three instalments, the concession rate and the course book.
- 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 |
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():
// 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():coursemust be a course id, names, email and phone are strings with control characters removed and a maximum length,experience,planandheardmust be one of the form's own values, andconcessionandbookmust betrueto 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
titleanddescriptionto<Layout>, which writes<title>, the meta description,og:title,og:description, asummary_large_imageTwitter card and atheme-color. The course page also updates the document title for the course in the URL. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://lingua.example.com). Set it to your domain when you build:shSITE_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 apublic/robots.txt. ConsiderCourseandEducationalOrganizationstructured 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-visibleinglobal.css), including on radio and checkbox cards; - one
h1per 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 witharia-describedbyandaria-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#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ 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 directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/coursesservescourses.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/coursestocourses.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=/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
idin the URL must match anidinCOURSES; 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 thatstartandendareHH: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 withurl()in pages, and withBASEinmain.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_URLto 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.