Overview#
Steer is a website template for a driving school or an independent driving instructor. The demo brand is
Brayline Driving School, a fictional school in Lincoln with six instructors teaching manual and automatic
lessons across the LN1 to LN6 postcode districts. It is built with plain HTML, Tailwind CSS v4 and one vanilla
JavaScript file, with no build step needed to run it. This is version 1.0.1 (see template.json and
CHANGELOG.md). Live demo: freeframe.dev/templates/steer.
Copy uses British English, prices are in pounds (GBP) and every date and time is UK time (Europe/London, GMT in
winter and BST in summer), whatever the visitor's own time zone.
Quick start#
- Unzip the download.
- Open
index.htmlin a browser. Everything works from the file system. - Or serve the folder, which is closer to how it will run online:
npx serve .You only need Node.js 24 or newer if you want to change the styles and rebuild the CSS (see Building and deploying).
Pages#
| File | Purpose | Notable sections and features |
|---|---|---|
index.html |
Home | Hero with postcode check and the next free lesson card, stats band, lesson types, four steps to a licence, instructor strip with next free times, theory teaser, latest passes, new-pupil offer |
lessons.html |
Lessons & prices | Price list, block booking table, intensive courses with start dates, Pass Plus, price calculator |
instructors.html |
Instructors | Gearbox, area and language filters, six instructor cards with portrait, car, areas, languages, days, hours, pass rate and next free lesson, "Choose" button |
book.html |
Book a lesson | Pick-up postcode check, gearbox, lesson length, instructor, seven-day week view of free times, details form, summary panel and confirmation |
theory.html |
Theory practice | Quiz start screen (quick ten, all 26, by topic), one question at a time with feedback, result ring and answer review, best scores |
areas.html |
Areas & test routes | Postcode checker, SVG district map with district picker, four test-route types |
passes.html |
Pupil passes | Live stats, gearbox, instructor and first-time filters, passes wall |
faq.html |
FAQ | Live search with highlighted matches, four question groups |
contact.html |
Contact | Office open-now status, contact form with a reference, office hours with today highlighted, street map |
404.html |
Not found | Three-point-turn illustration and links back into the site |
Project structure#
steer/
index.html … 404.html the ten pages (header and footer are repeated in each)
assets/
css/style.css compiled Tailwind CSS (generated: don't edit by hand)
js/main.js all the data and interactions
img/favicon.svg the logo mark, used as the favicon
img/og-image.svg source of the social sharing image
img/og-image.png the social sharing image (1200 × 630)
img/instructors/*.svg one illustrated portrait per instructor
src/input.css Tailwind source: brand tokens and shared components
package.json scripts to rebuild the CSS
README.md short introduction
DOCS.md this guide
CHANGELOG.md version historyCustomising#
Brand and colours#
The design tokens live in the @theme block at the top of src/input.css:
| Token | Default | Used for |
|---|---|---|
--color-route |
#0b4f6c |
Brand blue: headings, buttons, links, selected states |
--color-route-dark |
#083b52 |
Button hover |
--color-night |
#062536 |
Footer, top strip, dark panels |
--color-teal / --color-teal-soft |
#0c6e78 / #dcf0f1 |
Eyebrows, map districts, soft panels |
--color-sun / --color-sun-deep / --color-sun-soft |
#ffc531 / #f2ae00 / #fff3d0 |
The warm accent: primary buttons, lane markings, highlights |
--color-ink / --color-slate / --color-mist |
#0d2230 / #48606f / #b7cdd7 |
Body text, secondary text on white, secondary text on dark |
--color-cloud / --color-line |
#f1f6f8 / #d3e1e7 |
Alternate section background, borders |
--color-go / --color-stop |
#17734a / #b42318 |
Free times, right answers / errors, wrong answers |
Yellow is only used behind dark text or on dark backgrounds, never as text on white. To change the brand blue:
@theme {
--color-route: #1d4e89;
--color-route-dark: #163c69;
}Then rebuild the CSS. The SVG artwork uses the same colours as literal hex values, so search the HTML and
assets/img/ for #0b4f6c and #ffc531 if you want the drawings to follow.
Typography#
Headings use Readex Pro (500, 600, 700) and body text Inclusive Sans (400, 600), loaded from Google Fonts by
the <link> in every page's <head>. To swap them, change that link on each page and the two font tokens in
src/input.css:
@theme {
--font-sans: "Inclusive Sans", ui-sans-serif, system-ui, sans-serif;
--font-display: "Readex Pro", ui-sans-serif, system-ui, sans-serif;
}Content#
Page text lives in the HTML files. Everything the interactions use lives in section 1 of assets/js/main.js:
| Constant | What it holds |
|---|---|
SCHOOL |
Name and phone number used in generated messages |
TRANSMISSIONS |
manual and automatic with their hourly rate |
LESSON_LENGTHS |
Lesson lengths offered when booking (minutes → label) |
FIRST_RATE, FIRST_HOURS |
New-pupil offer: the first 2 hours at £25 |
BLOCK_TIERS |
Block discounts: £ off each hour from 10, 20 and 30 hours |
STUDENT_DISCOUNT, TEST_CAR_PRICE, PASS_PLUS_PRICE |
5%, £80 and £195 |
INTENSIVES |
Intensive courses: hours, weeks, prices per gearbox, who it suits |
INTENSIVE_LEAD_DAYS |
How far ahead the next intensive course starts (14 days) |
AREAS, NEARBY |
Covered postcode districts with their villages, and by-arrangement districts |
INSTRUCTORS |
The six instructors (see below) |
BOOK_AHEAD_DAYS, DEMO_BUSY, CLOSE_ON_BANK_HOLIDAYS, CHRISTMAS_BREAK, EXTRA_CLOSED |
Booking window and diary rules |
OFFICE_HOURS |
Office hours per weekday for the contact page status |
TOPICS, QUESTIONS |
Theory quiz topics and the 26 questions |
PASSES |
The pupil passes wall |
KEEP_DEMO_CURRENT, DEMO_ANCHOR |
Keep the passes wall recent (see Demo dates) |
Prices also appear as text in index.html and lessons.html (the price list and the calculator labels). When you
change TRANSMISSIONS or the discounts, update that copy too.
To add an instructor, add an entry to INSTRUCTORS and a portrait at assets/img/instructors/<id>.svg:
sian: {
name: 'Sian Example', short: 'Sian', role: 'Instructor since 2022', t: 'manual',
car: { body: 'hatch', colour: '#17734a', label: 'Green five-door hatchback', fuel: 'Petrol' },
areas: ['LN2', 'LN3'], languages: ['English'], days: [1, 2, 3, 4, 5], hours: ['09:00', '17:00'],
firstTime: 70, good: ['Nervous learners'],
bio: 'One or two sentences about how they teach.'
}t is manual or automatic, days uses 0 = Sunday … 6 = Saturday, hours are the first start time and the end
of the working day, and car.body is one of hatch, suv, saloon or estate (drawn by carSvg()). The cards,
filters, booking form, areas panel and passes filter all read from this object. Also add the portrait to the hero
grid in instructors.html if you want it there.
To add a theory question, add an object to QUESTIONS; answer is the index of the right option:
{ topic: 'rules', q: 'Your question?', options: ['A', 'B', 'C', 'D'], answer: 2, why: 'A short explanation.' }Write your own questions: the official DVSA revision question bank is licensed separately.
To add a pass, add an object to PASSES with a past date (YYYY-MM-DD), an instructor id, t, first
(first-time pass), minors, hours, area and a short quote. Only publish pupils who have agreed to it.
Images and artwork#
All artwork is inline SVG or SVG files: the hero roundabout scene, lesson-type icons, the motorway scene, the quiz
card, the district map, four test-route drawings, the office street map and the 404 three-point turn are inline in the
pages; instructor portraits are assets/img/instructors/*.svg (200 × 200, shown in circles); instructor cars are
drawn by carSvg() in main.js from each instructor's car colour and body. To use photos instead, replace the
portrait src in portrait() with your own square images (at least 400 × 400).
The social image is assets/img/og-image.png (1200 × 630), generated from assets/img/og-image.svg. Edit the SVG,
then export a PNG at the same size with any SVG tool. assets/img/favicon.svg is the logo mark.
Navigation, header and footer#
The header (top strip, logo, desktop menu, mobile menu) and the footer are repeated in every page. To add or remove a
page, edit the <nav aria-label="Main"> list, the #mobile-menu list and the footer lists in each HTML file, and
mark the current page's links with aria-current="page". The desktop menu uses short labels (Prices, Theory,
Areas…); the mobile menu and footer use the long ones.
Features#
Lesson availability#
Section 3 of main.js works out each instructor's free times from today, so the demo never shows a past or empty
diary. freeUnits(id, key) splits a working day (from hours[0] to hours[1], on days only) into half-hour units
and marks some as already booked with a seeded pseudo-random pattern (hash()), roughly DEMO_BUSY per cent of the
day. startsFor() lists the on-the-hour start times with enough free units for a lesson, slotsOn() merges
instructors for a day and nextFree() finds the earliest free lesson for a filter (t, area, instructor).
Lessons can be booked from tomorrow (FIRST_BOOKABLE) up to BOOK_AHEAD_DAYS (28) days ahead. Sundays have no
lessons because no instructor lists day 0; bank holidays, the Christmas break and EXTRA_CLOSED days are skipped by
closedReason().
To connect a real diary, replace freeUnits() with data from your booking system (an array of free half-hour units
per instructor and day), or replace startsFor() entirely.
Bank holidays and closures#
bankHolidays(year) returns the England and Wales bank holidays for any year, including Easter and substitute days.
Christmas Day and Boxing Day are always closed, and CHRISTMAS_BREAK closes every day from 24 December to 1 January
inclusive, whatever weekday Christmas falls on (so 27 and 28 December are never bookable, substitute day or not):
const CHRISTMAS_BREAK = { from: '12-24', to: '01-01', label: 'Christmas break' };Set it to null to teach between Christmas and New Year (bank holidays and their substitute days are still
skipped), and CLOSE_ON_BANK_HOLIDAYS to false to teach on bank holidays. Other closures go in EXTRA_CLOSED:
{ md: 'MM-DD', label } closes that date every year, { md: 'YYYY-MM-DD', label } once (a coronation, a jubilee).
Next free lesson#
The top strip of every page ([data-next-slot-short]) and the home page card ([data-next-card]) show the next free
2-hour lesson with anyone, link to book.html with that slot, instructor and gearbox filled in, and count the free
times in the next seven days. The instructor cards, home strip and areas panel show each instructor's next free hour.
Booking#
book.html ([data-booking]) has four parts:
- Postcode: checked with
parsePostcode()andcoverage(). A covered district narrows the instructor list to those who teach there; others in the list are disabled with "doesn't cover LN3". - Lesson: gearbox, length (
LESSON_LENGTHS) and instructor. The instructor list only shows instructors for the chosen gearbox. Picking one saves it (see Saved data). - Week view (
[data-week]): seven days at a time from tomorrow, with previous and next buttons up to four weeks ahead. Each day lists free start times as buttons witharia-pressed; a day off shows why (Sunday, a bank holiday name, the Christmas break). When several instructors are free at once, the saved instructor wins, then the first inINSTRUCTORS. If nothing is free in the visible week, a link jumps to the next free time. - Details: name, mobile, email, provisional licence status, first-lesson offer and notes.
The summary panel shows the pick-up, lesson, instructor, time and price (the first two hours at FIRST_RATE when "This
is my first lesson" is ticked). Submitting shows a confirmation with a reference such as BL-4K7QZ and what happens
next. Changing the gearbox, length or instructor drops a picked time that is no longer free.
Postcode checker#
Every [data-postcode-form] (home and areas pages) and the booking form accept a full postcode (LN6 7TS), the first
half (LN6) or the first half and sector (LN6 7). The answer is "covered" (districts in AREAS, listing villages
and instructors), "by arrangement" (NEARBY) or "not covered". On the areas page a covered result also selects that
district on the map; a by-arrangement result highlights its pill.
Price calculator#
lessons.html#calculator ([data-calc]) prices weekly lessons or an intensive course with quote():
- Weekly lessons: 1 to 45 hours. The first
FIRST_HOURShours are atFIRST_RATEfor new pupils; the rest at the hourly rate less the highest matchingBLOCK_TIERSdiscount. Students getSTUDENT_DISCOUNToff blocks of 10 hours or more. The test car is optional. - Intensive courses: the fixed
INTENSIVESprice, which includes the test car; the new-pupil offer doesn't apply. - Pass Plus can be added to either.
It shows each line, the total, the average per hour (everything but Pass Plus divided by the lesson hours, labelled "test car included" whenever the test car is in the price, as it always is for an intensive course), the saving against pay-as-you-go and a rough number of weeks at two hours a week. The block table and intensive cards on the same page are built from the same constants. "Copy link" copies a URL with the choices; if the clipboard isn't available the link is shown instead.
Intensive course dates#
intensiveStarts() lists the next three Mondays at least INTENSIVE_LEAD_DAYS days away whose Monday to Friday has
no bank holiday or closure, so the dates always roll forward.
Instructors#
instructors.html builds a card for each INSTRUCTORS entry with filters for gearbox (chips with aria-pressed),
pick-up area and language (selects), a live count and an empty state. "Choose Lowri" saves the instructor; the button
then reads "Your instructor" and the hero shows the name with a "Book with them" link. Click again to clear it.
?t=manual and ?area=LN4 preset the filters.
Theory quiz#
theory.html ([data-quiz]): choose "Quick ten" (ten random questions), "All 26" or one topic, then answer one
question at a time. Answers are real radio buttons; "Check answer" marks them right or wrong and explains, and the
final screen shows a score ring, a pass-mark message (86%, as in the real test) and a review of every question with a
"Only the ones I missed" toggle. "End quiz" finishes early with the questions answered so far. The best score per mode
or topic is saved and shown in the sidebar; "Clear my scores" removes them. ?mode=full and ?topic=weather preset
the start screen.
Areas map#
The map in areas.html is inline SVG: each district is a path.district and a g.district-label with
data-district="LN1". The district buttons ([data-district-buttons]) are built from AREAS; choosing one sets
data-active on the map shapes and fills the panel with the district's villages, instructors and their next free
lesson. ?area=LN4 opens a district. If you change the districts, redraw the paths and labels to match.
Passes wall#
passes.html shows PASSES newest first with gearbox, instructor and first-time filters, a live count and stats
(passes, first-time share, average minors, latest pass). ?i=lowri presets the instructor. The home page shows the
three latest.
Demo dates#
The passes are written for the week of DEMO_ANCHOR (2026-10-21). While KEEP_DEMO_CURRENT is true, every pass
date moves forward by the whole weeks since then, so weekdays stay right and the wall always looks recent; on the day it
was written the dates are exactly as typed. On a real site set it to false and enter real pass dates:
const KEEP_DEMO_CURRENT = false;Everything else that is dated is worked out from today by rule: lesson availability, the next free lesson, intensive
course start dates, the office status, relative dates ("3 days ago") and the copyright year in the footer
([data-year]). ?at=2027-03-15T10:30 previews any page at a UK date and time.
FAQ search#
[data-faq-search] filters the details[data-faq] items as you type (every word must match), hides empty groups,
opens and highlights matches with <mark>, and announces "3 questions match …" or shows a "Nothing matches" box.
?q= prefills the search.
Header, mobile menu, reveals and back to top#
The mobile menu button has aria-expanded and aria-controls; Esc closes it and returns focus. Elements with
.reveal fade up once as they scroll into view (instantly with reduced motion). The back-to-top button is the separate
block at the end of main.js; sticky panels marked data-back-to-top-hide hide it while it would cover them.
Forms#
| Form | Where | Validates |
|---|---|---|
| Postcode check | index.html, areas.html [data-postcode-form] |
A UK postcode or its first half |
| Booking request | book.html [data-booking] |
Covered postcode, a picked time, name, UK phone, email, licence status |
| Contact | contact.html [data-contact-form] |
Name, email, optional UK phone, message (10+ characters); ?topic= preselects the subject |
Errors appear under each field ([data-error="name"]) with aria-invalid, and focus moves to the first problem. The
forms don't send anything until you connect them. To connect one, post it where the comments in main.js say, for
example with Formspree:
// in the contact submit handler, before showing the thank-you panel
fetch('https://formspree.io/f/your-id', {
method: 'POST',
body: new FormData(contact),
headers: { Accept: 'application/json' }
}).then((res) => {
if (!res.ok) throw new Error('Send failed');
// show the thank-you panel here
});The booking time isn't a form field, so add it before sending:
const data = new FormData(booking); data.append('date', st.slot.key); data.append('time', hhmm(st.slot.mins)); data.append('instructor', st.slot.id);.
For live availability, connect the diary as described in Lesson availability.
Saved data (localStorage)#
| Key | Shape | Validated on read |
|---|---|---|
steer:instructor |
"rhona" |
Must be an own key of INSTRUCTORS; anything else counts as no choice. |
steer:quiz |
{ "quick": { "score": 8, "total": 10, "date": "2026-10-21" }, "topic-weather": { … } } |
Keys must be quick, full or topic- plus a TOPICS id; score and total must be whole numbers with 0 ≤ score ≤ total ≤ 26; date must be a real YYYY-MM-DD date. Invalid entries are dropped. |
steer:plan |
{ "mode": "weekly", "t": "manual", "h": 20, "course": "i30", "first": true, "student": false, "testcar": true, "plus": false } |
mode must be weekly or intensive, t an own key of TRANSMISSIONS, h a whole number from 1 to 45, course an own key of INTENSIVES, the rest booleans; anything else falls back to the defaults. |
Contact and booking details are not stored. All storage goes through one safeStorage object: localStorage, or an
in-memory stand-in when the browser blocks storage (a sandboxed iframe, strict privacy settings), so choosing an
instructor, quiz scores and the calculator keep working for the page view. To reset, use "Clear my scores" on the
theory page, click your chosen instructor again, or run localStorage.removeItem('steer:plan') in the browser console.
URL parameters#
| Parameter | Page | Values |
|---|---|---|
?at=2027-03-15T10:30 |
every page | Preview the site at a UK date and time |
?i=, ?t=, ?d=, ?slot=, ?postcode= |
book.html |
Instructor id, manual/automatic, 60/90/120, YYYY-MM-DDTHH:MM, a postcode |
?mode=, ?t=, ?h=, ?course= |
lessons.html |
weekly/intensive, gearbox, hours (1–45), an INTENSIVES id |
?t=, ?area= |
instructors.html |
Gearbox filter, an AREAS id |
?mode=, ?topic= |
theory.html |
quick/full, a TOPICS id |
?area= |
areas.html |
An AREAS id |
?i= |
passes.html |
An instructor id |
?q= |
faq.html |
Search words (first 80 characters) |
?topic= |
contact.html |
lessons, test, intensive, area, gift or other |
Ids are always checked with Object.prototype.hasOwnProperty.call(...), so ?i=constructor or ?area=__proto__ are
ignored. A ?slot= is only used if that time is still free for the chosen instructor and gearbox.
SEO and sharing#
Each page has its own <title>, meta description, Open Graph tags and theme-color in its <head>, with
lang="en-GB". After deploying, change og:image to an absolute URL (for example
https://www.yourschool.co.uk/assets/img/og-image.png), since social sites don't read relative image paths. Add a
sitemap.xml and robots.txt at the site root if your host doesn't generate them. For local search, consider adding
DrivingSchool structured data (JSON-LD) with your address, phone and hours to index.html and contact.html.
Accessibility#
- Skip link, landmarks, one
<h1>per page and a logical heading order. - Visible focus outlines everywhere (blue on light, yellow on dark).
- Filter chips, district buttons and time slots use
aria-pressed; time slots have full labels such as "Friday 23 October, 10am with Lowri Pennick". - Quiz answers are native radio buttons in a
fieldset; focus moves to each new question and to the result. - Counts, results and confirmations are live regions (
role="status"/aria-live="polite"); form errors usearia-invalidandaria-describedby. - Drawings have
role="img"with a<title>; decorative portraits have emptyalt. - Colour contrast meets WCAG AA; yellow is only used behind dark text or on dark backgrounds.
- Scroll reveals, the progress bar and smooth scrolling respect
prefers-reduced-motion.
When editing, keep one <h1> per page, give every new input a <label>, and keep aria-describedby ids in step with
the error elements.
Building and deploying#
Rebuilding the CSS#
The compiled assets/css/style.css is included. If you add Tailwind classes or change src/input.css:
npm install
npm run build # or: npm run watchTailwind finds classes in the HTML and in assets/js/main.js; write class names in full (not built from pieces).
Deploying#
Upload the folder to any static host: Netlify Drop, Cloudflare Pages, GitHub Pages, Vercel or your own server. No
server code is needed. Leave out src/, package.json and node_modules/ if you like; the site only needs the HTML
files and assets/.
Troubleshooting#
- The week view shows no free times. Check
daysandhoursfor each instructor,BOOK_AHEAD_DAYSandDEMO_BUSYinassets/js/main.js. A highDEMO_BUSYfills the demo diary; set it to0to show every hour free. - A bank holiday is missing.
bankHolidays()follows the usual England and Wales rules; add one-off holidays toEXTRA_CLOSEDas{ md: 'YYYY-MM-DD', label }. Scotland and Northern Ireland have different bank holidays. - A postcode is rejected.
parsePostcode()accepts standard UK formats. Add districts toAREASorNEARBYto cover them. - Prices don't match between pages. Prices live in
TRANSMISSIONS,BLOCK_TIERS,INTENSIVESand the other price constants, and as text inindex.htmlandlessons.html; update them together. - Saved choices disappear after closing the tab. The browser is blocking storage (private mode, a sandboxed iframe); the site falls back to memory for that page view.
- The passes wall shows different dates from the code.
KEEP_DEMO_CURRENTis moving them forward; set it tofalse. - New Tailwind classes have no effect. Rebuild the CSS (
npm run build). - The social preview has no image. Make
og:imagean absolute URL after deploying.
License#
MIT. The code and all included artwork are free for personal and commercial use; the full text is in LICENSE.txt,
included in the download. Readex Pro and Inclusive Sans are licensed under the SIL Open Font License. Brayline Driving
School, its instructors, pupils, passes and reviews are fictional, and the theory questions are original practice
questions, not the official question bank.