Northwind

Northwind, documentation

A landing page for an event planning business, with a brief form that really sends and a set of tabs that need no JavaScript at all.

1. Quick start

Three steps and the page is live with a working form.

  1. Open fillup.php in a plain text editor. Notepad is fine, Word is not.
  2. Find $TO near the top and put your own email address there.
  3. Upload the whole folder to your web host, keeping the folder structure.

Then send yourself one test brief from the page and check three things.

  1. The email arrives, and is not in your spam folder.
  2. A file appeared at storage/briefs-2026-01.csv with your test in it.
  3. Typing that CSV address into a browser gives 403 or 404, never a download.

Do the third one before you take real enquiries. That folder holds your clients' names, event dates and budgets. Section 7 explains what to do if the file downloads.

What you need

A host that runs PHP 7.4 or newer, which is nearly all of them. Nothing to install, no Composer, no libraries, no account anywhere. The page itself is plain HTML and CSS and works on any host at all; only the form needs PHP.


2. What is in the folder

index.html the page itself
fillup.php your settings and wording. The only file you have to edit.
send.php receives the brief, checks it, saves it, emails it
css/style.css all styling, with every colour set at the top
js/main.js one file, no libraries
images/ every picture on the page, JPEG and WebP
storage/ where briefs are saved. Read the note inside.
docs/ this documentation
README.txt the short version of this file

The two files you will touch most are index.html for wording on the page and fillup.php for wording in the emails. You should not need to open send.php at all.


3. The sections

Every block in index.html starts with a comment like <!-- ===== 4. What we run ===== -->, so searching for the number takes you straight there.

#SectionWhat it is for
1HeaderSticky, and transparent to begin with so the dark hero shows through. It turns solid once the page has moved.
2HeroDark. Large type on the left, two overlapping photographs on the right.
3RibbonA line of event types sliding slowly sideways. Pure CSS.
4What we runFive tabs. Radio buttons and CSS, no JavaScript.
5What we handleA ten item checklist beside a photograph and a small card.
6Parallax bandA photograph that scrolls slower than the page, with a statement over it.
7How we workFive stages either side of a centre line.
8Recent workSix photographs with captions on them. Any of them opens in a popup.
9The brief formNine fields on a dark band, in one centred column.
10QuestionsSix questions and answers, two across, nothing hidden.
11ClosingA short dark band with one button.
12FooterCopyright line and the credit.

Removing a section

Delete the whole block, from its comment down to the closing </section>. Nothing else breaks, except that any link in the header pointing at it should go too. The one section you cannot delete is number 9, because every button on the page scrolls to it.


4. Colours and fonts

Every colour is a variable at the top of css/style.css. Change one there and it changes everywhere.

:root {
  --ink:      #17131F;   /* headings, body text, primary button */
  --ink-soft: #5C5468;   /* second-rank text */
  --gold:     #A0731C;   /* the accent, on pale backgrounds */
  --gold-br:  #E0B44A;   /* the accent, on dark backgrounds */
  --cream:    #FAF6EF;   /* the pale section background */
  --line:     #E7DFD2;   /* hairlines */
  --dark:     #17131F;   /* the dark sections */
  --dark-2:   #221C2E;   /* panels sitting on a dark section */
}

Two golds, on purpose. --gold is dark enough to read as text on cream. --gold-br is bright enough to read on the near-black sections. Swapping in one colour for both is the quickest way to make this template look wrong, because one of the two places will lose its contrast.

Fonts

Headings and anything structural use the system sans-serif face. Body copy is a serif. That split is most of the page's character, so if you change one, change the other to match. Both are fonts the reader already has, so there is nothing to download and nothing slowing the page down.

To use a web font instead, add its <link> in the head of index.html, then change the font line in the body rule for body copy, and the font-family in the h1, h2, h3, .sans rule for headings.

Corners

--r and --r-lg are small on purpose: this template is nearly square cornered, which is what makes it read as formal next to a rounder design. Raise them both to about 14px and 22px for a softer look.


5. The tabs, and adding one

Section 4 of the page has five tabs. There is no JavaScript behind them at all. They are radio buttons, hidden from sight but not from the keyboard, and CSS decides which panel to show based on which one is checked.

Three things follow from that. They work with scripting switched off. The arrow keys move between them, because that is what a group of radio buttons does by itself. And there is no flash of every panel at once while a script loads, because no script is loading.

The shape of it

<div class="tabs">
  <input class="tab-radio" type="radio" name="what" id="tab1" checked>
  <input class="tab-radio" type="radio" name="what" id="tab2">
  ... one input per tab ...

  <div class="tab-bar">
    <label for="tab1">Conferences and expos</label>
    <label for="tab2">Galas and award nights</label>
    ... one label per tab, in the same order ...
  </div>

  <div class="tab-panels">
    <article class="tab-panel"> ... </article>
    <article class="tab-panel"> ... </article>
    ... one panel per tab, in the same order ...
  </div>
</div>

The order matters in all three lists. Input one goes with label one and panel one.

Adding a sixth

  1. Add <input class="tab-radio" type="radio" name="what" id="tab6"> after the fifth input.
  2. Add <label for="tab6">Your title</label> at the end of the tab bar.
  3. Copy a whole <article class="tab-panel"> to the end of the panels and change its words and picture.
  4. In css/style.css, find the two rules that list the tab numbers and add a line to each:
#tab6:checked ~ .tab-bar label:nth-of-type(6) { ... }
#tab6:checked ~ .tab-panels .tab-panel:nth-of-type(6) { display: grid; }

Step four is the one people miss. A sixth tab with no matching CSS never appears when you click it, and nothing tells you why. Both rules are marked with a comment in the stylesheet saying to copy a pair and bump both numbers.

Removing a tab is the same in reverse: delete the input, the label, the panel and the two lines. If you remove the first one, move checked onto whichever input is now first, or the section opens with nothing showing.


6. The brief form

One form, nine fields. It asks for enough to write a sensible first reply and no more.

FieldName in the HTMLRequired
Your namenameno
Emailemailyes
Phonephoneno
Company or organisationcompanyno
Kind of eventeventno
Dateeventdateno
How many peopleguestsno
Venuevenueno
Budget rangebudgetno
The briefmessageno

Why the email is the required one. A proposal for an event gets emailed, not read out over the phone. If you would rather chase people by telephone, open send.php, find the two validation blocks near the comment that says so, and swap which one is allowed to be empty.

The date box

It is a real <input type="date">, so a modern browser shows its own date picker and sends the date as 2026-11-14. An older browser turns it into a plain text box instead, which is why send.php accepts anything sensible there rather than insisting on a format.

The wording in the emails

All of it is in fillup.php, in seven numbered parts. Words in curly brackets are swapped for the real details when the message is sent:

{name} {email} {phone} {company} {event} {eventdate} {guests} {venue} {budget} {message} {site} {date}

{eventdate} and {date} are not the same thing. {eventdate} is the day of their event. {date} is the moment the brief was sent. Mixing the two up is easy and reads badly in a confirmation email.

Spell every token exactly, in lower case. A token spelled wrong prints as written rather than vanishing, which is how you spot it.

Spam

Three things run on every submission.

A caught submission is shown the ordinary thank you rather than an error. Telling a robot it was caught only teaches whoever wrote it what to change. The details go to storage/trapped-*.csv, so a real person caught by mistake is never lost. Look in that file now and then.

Do not delete the hidden fields. The form carries website and ts. Remove them and your spam rises sharply within days.


7. Where briefs are saved

Every brief is written to a CSV file before any email is attempted. An email can fail for a dozen reasons outside your control; a written file cannot. The worst an email problem can cost you is a notification, never the enquiry.

storage/briefs-2026-01.csv every brief, one file per month
storage/trapped-2026-01.csv what the spam traps stopped
storage/mail-failures.log written only when an email did not go out
storage/rate.json counts recent submissions, addresses hashed

Open the CSV in Excel or Google Sheets. It is written with a byte order mark so accented characters come out right.

Keeping the folder private

There is an .htaccess file inside storage/ that blocks web access, written in both the Apache 2.2 and the Apache 2.4 way so it works on either. That covers most shared hosting.

If your host runs nginx, .htaccess is ignored. Either move the storage folder above your web root and point $LOG_DIR in fillup.php at the new place, which is the better answer, or add a deny rule for /storage/ to your nginx config.

Either way, after your site is live, type the address of one CSV file into a browser. You want 403 or 404. If it downloads, your clients' budgets are public.


8. If email does not arrive

The brief is already saved, so nothing is lost. What has failed is only the notification.

PHP's built-in mail function is unreliable on some shared hosting, and mail sent that way often lands in spam because nothing proves it came from you. The fix is to send through your own mailbox instead. Fill in the $SMTP block at the bottom of fillup.php and change 'enabled' to true.

LineWhat to put
hostYour outgoing mail server. Whoever gives you email tells you this. Usually smtp.yourdomain.com, smtp.gmail.com or mail.yourhost.com.
port587 in most cases. 465 only if your provider says so.
securetls goes with 587, ssl goes with 465. They come as a pair.
userYour full email address, including the @ part. Not just the bit before it.
passThat mailbox's password.

Gmail, Outlook and Yahoo will refuse your normal password. They make you create a separate "app password" in your account security settings, and that is what goes in pass. If signing in keeps failing, this is almost always the reason.

Also set $FROM near the top of fillup.php to the same address as user. Many providers refuse a message that claims to come from somewhere else.

The password sits in the file as ordinary text. Use a mailbox created for the website rather than your personal one, and never put a copy of fillup.php anywhere public.

Would you rather use PHPMailer?

It drops in without changing anything else. Replace the body of the send_mail() function in send.php with your PHPMailer calls; every other part of the script carries on as it is. The built-in sender is here so that the template works with nothing installed, not because it is better.


9. Images

Every picture is in images/, twice: a .jpg that works everywhere and a .webp that is roughly half the size. The browser picks the smaller one it understands.

FileSizeWhere it appears
hero-a640 x 800The large hero photograph, portrait
hero-b520 x 390The smaller one overlapping it
tab-expo, tab-gala, tab-festival, tab-summit, tab-workshop900 x 620One per tab, section 4
handle800 x 640Beside the checklist, section 5
band1600 x 900The parallax band, section 6
g1 to g61200 x 900Recent work, and the popup
top144 x 144The back to top button

Swapping a picture

  1. Crop your photograph to the size in the table above.
  2. Save it over the old file, keeping the same name.
  3. If you cannot make a WebP, delete the matching .webp file and remove its <source> line. The JPEG then does the work on its own.

Keep the width and height attributes matching the real file. They are on every <img> tag. They tell the browser how much room to leave before the picture arrives, and getting them wrong makes the page jump about while it loads.

The photographs are placeholders. They are not covered by the licence on this template. Replace them with your own, or with pictures you have the right to use, before you publish.

The logo

It is drawn in the HTML, not a picture file, so it is sharp everywhere and takes its colour from --gold-br. Replace the <svg> inside <span class="logo-mark"> with your own, or swap the whole span for an <img> of your logo.


Six photographs, three across. Every tile is a real <button>, which means it works from the keyboard as well as the mouse.

<button class="work-item" type="button" data-cap="Awards night, 320 guests">
  <picture>
    <source type="image/webp" srcset="images/g1.webp">
    <img src="images/g1.jpg" alt="..." width="1200" height="900" loading="lazy" decoding="async">
  </picture>
  <span class="work-cap">Awards night, 320 guests</span>
</button>

The caption appears twice on purpose: once inside data-cap, which is what the popup prints, and once as the <span> printed on the photograph itself. Change both, or they will disagree.

That caption sits on the picture rather than appearing on hover, because there is no hover on a phone and a caption nobody can reach is not a caption.

Add or remove tiles freely; the popup counts whatever it finds. Inside it, the arrows move between photographs, the arrow keys do the same, Escape closes it, and a swipe works on a phone. While it is open the keyboard stays inside it, and when it closes the focus returns to the tile you came from.

Why the popup opens instantly. It reuses the file the browser already chose and cached for the tile, rather than fetching a bigger one. For a larger picture in the popup, point pic.src in block 5 of js/main.js at a separate file instead.


11. The ribbon, parallax and motion

The ribbon

The gold strip under the hero slides sideways forever, and it is pure CSS. It works by holding two identical copies of the list side by side and sliding the pair left by exactly half their width. When the animation restarts, the second copy is sitting precisely where the first began, so the join cannot be seen.

That is why the list appears twice in the HTML. If you change the words, change both copies, or the ribbon will stutter every time it loops. The second copy carries aria-hidden="true" so a screen reader only reads it once.

Speed is the 44s in the animation line on .ribbon-track. Larger is slower.

Parallax

The photograph band scrolls slower than the page. It is done in JavaScript rather than CSS because the CSS way, background-attachment: fixed, does nothing at all on an iPhone.

The picture inside the band is 140% of the band's height and pulled up 20%, which leaves 20% of slack above and below. The script never moves it more than 18% of the band height, so an edge can never appear. To make the movement stronger, raise DRIFT in block 4 of js/main.js, and raise the height and top on .band-pic to match.

To turn it off, delete the data-parallax attribute from <div class="band-pic">. The photograph then sits still and nothing else changes.

People who ask for less movement

Anyone whose computer is set to reduce motion gets a still ribbon, no parallax and no smooth scrolling. The page checks prefers-reduced-motion in the stylesheet for the ribbon and once at the top of the script for the rest.


12. Without JavaScript

Everything works. This is worth knowing because it is unusual.

FeatureWith JavaScriptWithout
The tabsChange panel on clickChange panel on click. They are radio buttons and CSS.
The ribbonSlidesSlides. It is a CSS animation.
The formSent in the background, replaced by a thank youPosts normally, send.php answers with a page of its own
Field errorsPrinted under the field that is wrongListed on the answer page
Recent workOpens a popupPhotographs still show, they simply do not enlarge
ParallaxThe photograph driftsThe photograph sits still
HeaderTurns solid on scrollStays transparent, which is still readable over the hero
Back to topAppears on scrollNot shown, nothing is lost

Nothing on the page is hidden by CSS and revealed by script, which is the usual reason a page arrives blank when a script fails to load.


13. Browsers

Chrome, Edge, Firefox and Safari, current versions and a good way back. There is no build step, no framework and no library, so there is nothing to keep up to date.


14. Troubleshooting

What you seeWhat it is
The page is blank after editing fillup.phpA missing quote mark or semicolon. Undo your last change. Use straight quotes, not curly ones.
A tab you added does nothingThe two CSS rules that list the tab numbers were not extended. Section 5.
The tabs open with nothing showingThe input carrying checked was deleted. Put it on whichever input is now first.
The ribbon jumps every few secondsThe two copies of the list no longer match. Section 11.
The form says it sent, but no email cameCheck storage/. If the brief is in the CSV, the form is fine and the email is the problem. Section 8.
Nothing appears in storage/PHP cannot write there. Set the folder to 755, or 775 if your host needs it.
The email arrives in spamSet $FROM to an address on your own domain, then turn on SMTP. Section 8.
The confirmation email shows the wrong date{date} was used where {eventdate} was meant. Section 6.
The page jumps about while it loadsAn image whose width and height attributes do not match the real file. Section 9.
A brief from a real person never arrivedLook in storage/trapped-*.csv. If they are in there, a spam trap caught them.
The CSV file downloads in a browserStop and fix this. Section 7.

15. Licence

Use this template for personal, commercial or client work, on as many sites as you like. Do not resell or redistribute the template files themselves.

The photographs that came with it are placeholders and are not covered. Replace them before you publish.

Full terms: 7uptheme.com/license