Nº 179 of 520
Boxing gym home with timetable
Graftworks: a black poster hero in 190px Anton with an angled orange stripe, a filterable timetable, a monthly/annual tier toggle and a sticky free-class button.
Preview
Use it
Free to use · credit appreciated1 Once, in your project
npx skills add SusanAcharya/Design-Lounge 2 Then ask
Using the Design Lounge skill, build "Boxing gym home with timetable" (piece gym-membership-home) in my stack. Match its brief; restyle it onto my product's theme.
No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.
<!-- Design Lounge Nº 179 · "Boxing gym home with timetable" · www.designlounge.live -->
# Boxing gym home with timetable
> **Build brief for a coding agent.** Rebuild this piece in the reader's stack. If they haven't said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don't "improve" them.
## What it is
The one-page home for a fictional boxing and strength gym, Graftworks, under a railway arch in Leeds. It is a fight poster first and a website second: black page, off-white type, one blood orange, ultra-condensed Anton display, JetBrains Mono for everything else, 0 radius, 2px borders. The hero headline "Hit harder. Lift heavy." runs at 190px with a −7° orange stripe crossing behind it carrying a slow ticker.
The page does the four jobs a gym site must do: show when classes are (a timetable with day tabs and two filters), show what it costs (three tiers with a monthly/annual toggle), show who coaches (four cards), and convert (a free-trial form plus a sticky "Book free class" button). The detail worth copying is the **timetable**: real tabs, two selects, a live count, a waitlist state, and an empty state, all from one data array.
## Reference behaviour
1. Initial state: hero visible. The orange stripe ticker scrolls left at a 38s loop. The sticky button is hidden because the hero is on screen.
2. Scroll past the hero: the sticky "Book free class" button slides up into the bottom-right corner (240ms). When the free-trial section enters the viewport it slides away again. Hidden means `aria-hidden="true"`, `tabindex="-1"` and `pointer-events: none`.
3. Timetable starts on Mon with all levels and all coaches: 5 classes. The count reads "5 classes".
4. Click a day tab (or press Left/Right while a tab is focused): that tab becomes selected (black fill, white text), focus moves with arrow keys, and the rows redraw for that day.
5. Change Level or Coach: rows filter at once. The count updates ("1 class" singular). With no match the table shows one row: "No classes match. Clear a filter or pick another day."
6. Each row: time, class name, level tag (Advanced is inverted), coach, spaces ("4 of 14 spaces") and a "Book" button. A class with 0 spaces shows orange "Waitlist" and the button reads "Join list".
7. Membership toggle: Monthly is pressed on load. Click Annual: prices become £390 / £690 / £1,190 and "/ month" becomes "/ year". The Annual button carries an orange "2 months free" note.
8. Free-trial form: submit empty and each missing field gets an orange border and a message under it. Focus moves to the first bad field. Fill first name, a valid email and tick the box: the status line reads "Booked, Sam. Boxing Fundamentals · Mon 18:30. Check your email for the door code."
9. Buttons press 2px down-right on `:active`.
10. At 960px and below the nav hides and a 44px square menu button opens a full-width black dropdown.
## Structure
```
1280 × 800 (first frame)
┌─────────────────────────────────────────────────────────────────────────┐
│ ▰ GRAFTWORKS TIMETABLE MEMBERSHIP COACHES FREE CLASS Arch 9 · 06–22 │ 64, 2px rule
├─────────────────────────────────────────────────────────────────────────┤
│ EST. 2016 BOXING / STRENGTH / CONDITIONING (12px mono, grey) │
│ HIT ┌───────────────────┐ │
│ HARDER. (orange) 190px Anton │ UNDER A RAILWAY… │ │
│ ╲╲╲╲ orange stripe −7°, 96px tall, ticker ╲╲╲╲╲╲╲╲ │ 41 classes a week │ │
│ LIFT HEAVY. │ body │ │
│ │ [BOOK FREE CLASS] │ │
│ │ [ SEE TIMETABLE ] │ │
│ └───────────────────┘ │
… 241 more lines. Copy or download for the full brief. Loading the source…Brief
281 linesBuild brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.
What it is
The one-page home for a fictional boxing and strength gym, Graftworks, under a railway arch in Leeds. It is a fight poster first and a website second: black page, off-white type, one blood orange, ultra-condensed Anton display, JetBrains Mono for everything else, 0 radius, 2px borders. The hero headline “Hit harder. Lift heavy.” runs at 190px with a −7° orange stripe crossing behind it carrying a slow ticker.
The page does the four jobs a gym site must do: show when classes are (a timetable with day tabs and two filters), show what it costs (three tiers with a monthly/annual toggle), show who coaches (four cards), and convert (a free-trial form plus a sticky “Book free class” button). The detail worth copying is the timetable: real tabs, two selects, a live count, a waitlist state, and an empty state, all from one data array.
Reference behaviour
- Initial state: hero visible. The orange stripe ticker scrolls left at a 38s loop. The sticky button is hidden because the hero is on screen.
- Scroll past the hero: the sticky “Book free class” button slides up into the bottom-right corner (240ms). When the free-trial section enters the viewport it slides away again. Hidden means
aria-hidden="true",tabindex="-1"andpointer-events: none. - Timetable starts on Mon with all levels and all coaches: 5 classes. The count reads “5 classes”.
- Click a day tab (or press Left/Right while a tab is focused): that tab becomes selected (black fill, white text), focus moves with arrow keys, and the rows redraw for that day.
- Change Level or Coach: rows filter at once. The count updates (“1 class” singular). With no match the table shows one row: “No classes match. Clear a filter or pick another day.”
- Each row: time, class name, level tag (Advanced is inverted), coach, spaces (“4 of 14 spaces”) and a “Book” button. A class with 0 spaces shows orange “Waitlist” and the button reads “Join list”.
- Membership toggle: Monthly is pressed on load. Click Annual: prices become £390 / £690 / £1,190 and ”/ month” becomes ”/ year”. The Annual button carries an orange “2 months free” note.
- Free-trial form: submit empty and each missing field gets an orange border and a message under it. Focus moves to the first bad field. Fill first name, a valid email and tick the box: the status line reads “Booked, Sam. Boxing Fundamentals · Mon 18:30. Check your email for the door code.”
- Buttons press 2px down-right on
:active. - At 960px and below the nav hides and a 44px square menu button opens a full-width black dropdown.
Structure
1280 × 800 (first frame)
┌─────────────────────────────────────────────────────────────────────────┐
│ ▰ GRAFTWORKS TIMETABLE MEMBERSHIP COACHES FREE CLASS Arch 9 · 06–22 │ 64, 2px rule
├─────────────────────────────────────────────────────────────────────────┤
│ EST. 2016 BOXING / STRENGTH / CONDITIONING (12px mono, grey) │
│ HIT ┌───────────────────┐ │
│ HARDER. (orange) 190px Anton │ UNDER A RAILWAY… │ │
│ ╲╲╲╲ orange stripe −7°, 96px tall, ticker ╲╲╲╲╲╲╲╲ │ 41 classes a week │ │
│ LIFT HEAVY. │ body │ │
│ │ [BOOK FREE CLASS] │ │
│ │ [ SEE TIMETABLE ] │ │
│ └───────────────────┘ │
├─────────────────────────────────────────────────────────────────────────┤ 2px rule
│ TIMETABLE (88px) WEEK OF 5 OCTOBER · 60 MIN │ light band
│ [MON][TUE][WED][THU][FRI][SAT][SUN] 2px black box, 48 tall │
│ Level [▾] Coach [▾] 5 classes │
└─────────────────────────────────────────────────────────────────────────┘
below: membership (black) · coaches (light) · free trial (black) · footer
fixed: [BOOK FREE CLASS] bottom-right 24px, 56 tall, 6px white offset shadow
header.top: sticky, 64px, black, 2px off-white bottom border. Logo is a 16×28px orange bar skewed −14° plus “GRAFTWORKS” in Anton 28px.- Hero
section:min-height: 560px,overflow: hidden, 2px bottom border. Inner gridminmax(0,1fr) 300px, 32px gap, items aligned to the bottom. - The stripe is an
aria-hiddenabsolutedivbehind the type (z-index 1, text z-index 2),left/right: -10%,top: 58%, rotated −7°. - The side box is a black panel with a 2px off-white border, 20px padding, so it stays readable over the stripe.
- Timetable:
div role="tablist"of sevenbutton role="tab", then a filter row, thendiv role="tabpanel"wrapping a<table>. - Membership:
div role="group" aria-label="Billing"with twoaria-pressedbuttons; three<article class="tier">in a 2px bordered 3-column grid. The middle tier is orange. - Coaches: four
<article>in a 2px bordered 4-column grid. Each has a 220px striped portrait block with initials, then text. - Free trial: 2 columns. Left: 120px headline “First class is free.” Right:
<form novalidate>in a 2-column grid. - Bands alternate black and
#f3f1ecand are split by 2px off-white rules.
Tokens
:root {
--black: #0c0c0c; /* page, dark bands */
--black-2: #161616; /* portrait stripe dark */
--white: #f3f1ec; /* type on black, light bands */
--grey: #a19d95; /* labels on black */
--grey-2: #6f6c66; /* labels on light */
--line: #2c2c2c; /* hairline on black (menu rows) */
--line-l: #d6d2ca; /* hairline on light (table rows) */
--orange: #ff4b1f; /* the one accent */
--display: "Anton", Impact, "Arial Narrow", sans-serif;
--mono: "JetBrains Mono", ui-monospace, monospace;
--fs-hero: 190px;
--fs-h2: 88px;
--fs-trial: 120px;
--fs-price: 84px;
--fs-tier: 44px;
--fs-body: 14px;
--fs-label: 12px;
--pad: 48px;
--band: 80px;
--border: 2px;
--radius: 0;
--t: 140ms;
--t-sticky: 240ms;
--ticker: 38s;
--ease: cubic-bezier(.2, .7, .2, 1);
}
Set border-radius: 0 on *. The only “shadow” is the sticky button’s hard 6px 6px 0 offset.
Typography
| Role | Family | Size | Weight | Line-height | Tracking | Case |
|---|---|---|---|---|---|---|
| Hero headline | Anton | 190px | 400 | 0.88 | -0.005em | UPPERCASE |
| Section heading | Anton | 88px | 400 | 0.88 | 0.005em | UPPERCASE |
| Trial headline | Anton | 120px | 400 | 0.88 | 0.005em | UPPERCASE |
| Price | Anton | 84px | 400 | 0.85 | 0 | numerals |
| Big stat | Anton | 64px | 400 | 0.9 | 0 | sentence, orange |
| Tier / coach name | Anton | 44px / 34px | 400 | 0.88 | 0 | UPPERCASE |
| Timetable time | Anton | 30px | 400 | 1 | 0 | numerals |
| Class name | Anton | 26px | 400 | 1 | 0 | UPPERCASE |
| Ticker | Anton | 44px | 400 | 1 | 0.02em | UPPERCASE, black on orange |
| Body | JetBrains Mono | 14px | 400 | 1.55 | 0 | sentence |
| Labels | JetBrains Mono | 12px | 400 | 1.55 | 0.14em | UPPERCASE |
| Buttons | JetBrains Mono | 13px | 700 | 1 | 0.08em | UPPERCASE |
| Level tag | JetBrains Mono | 11px | 700 | 1 | 0.1em | UPPERCASE |
Never set body copy in Anton. Never set a heading in the mono.
Motion
| Element | Trigger | Property | From → To | Duration | Easing | Reduced motion |
|---|---|---|---|---|---|---|
| Stripe ticker | load, loop | translateX | 0 → -50% | 38s | linear (ticker only) | static |
| Sticky button | hero or trial in view | translateY, opacity | 0 → 140%, 1 → 0 | 240ms | --ease | 1ms |
| Buttons | hover | background, colour | swap fill | 140ms | --ease | 1ms |
| Buttons | active | transform | 0 → translate(2px,2px) | 140ms | --ease | none |
| Sticky button | hover | box-shadow colour | white → orange | 140ms | --ease | 1ms |
| Day tab, toggle | click | fill | instant | 0 | — | — |
The ticker text is the phrase repeated three times plus a trailing space, so moving it −50% loops without a jump. It is the one place linear is right.
States
- Day tab: 48px tall, 2px black dividers. Hover
#e4e0d8. Selected: black fill, off-white text,aria-selected="true",tabindex="0". Otherstabindex="-1". - Select: 44px, 2px black border, off-white fill, 200px min width, bold.
- Level tag: 1.5px black outline. Advanced: black fill, off-white text.
- Spaces: “4 of 14 spaces” in
--grey-2with the coach name bold. Zero: orange “Waitlist”, button “Join list”. - Empty timetable: one full-width row, bold, “No classes match. Clear a filter or pick another day.”
- Billing toggle: 2px off-white box, two 44px buttons. Pressed: off-white fill, black text.
- Tier, default: black, 2px off-white dividers. Tier, featured: orange fill, black text, black button.
- Primary button: orange fill and border, black text. Hover: off-white fill.
- Ghost button: 2px off-white border. Hover: off-white fill, black text.
- Input invalid: border turns orange, message 12px orange under the field,
aria-invalid="true". - Success: bold orange status line under the submit.
- Focus-visible: 3px orange outline, 3px offset, on everything.
- Sticky hidden:
.offclass, translated down 140%, opacity 0.
Accessibility
- One
h1(the hero lines in three spans). Each band has anh2andaria-labelledby. - Tabs follow the ARIA tabs pattern:
role="tablist"with a label “Day”,role="tab"witharia-selectedandaria-controls, rovingtabindex, Left/Right arrows wrap around seven tabs. The table sits inrole="tabpanel". - The class count is
aria-live="polite", so filter changes are announced. - Filters are real
<select>elements wrapped in<label>. - The billing toggle is
role="group"with twoaria-pressedbuttons. Prices are plain text so they read out after a change. - The form uses
novalidateand does its own checks so the messages match the brand. Each required input hasaria-describedbypointing to its message. The status line isrole="status". - The stripe and the hero meta line are
aria-hidden; the ticker is decoration. - Portraits are
aria-hiddeninitials. Names are inh3. - The sticky button is a link to
#trial. When hidden it isaria-hidden="true"andtabindex="-1". - Contrast: off-white on black is about 17:1. Black on orange is about 6.7:1.
#a19d95on black is about 7:1.#6f6c66on off-white is about 4.8:1. Orange text on black (prices on the toggle, “Waitlist” on light) is display weight or bold only. - Hit targets: buttons 48px, tabs 48px, selects 44px, menu 44px, sticky 56px, checkbox 22px inside a full-width label.
Responsive rules
- ≥1280: as specified. Hero 190px. Section headings 88px. Tiers 3 across. Coaches 4 across. Trial 2 columns.
- 1024–1279 (below 1180px): hero 150px. Section headings 72px. Trial headline 96px.
- 768–1023 (below 960px): hide nav and address. Show the menu button. Hero 120px, side box drops under the headline. Tiers stack 1 column with 2px rules between. Coaches 2 × 2. Trial stacks.
- <640: side padding 20px. Hero
min-heightauto, 72px top padding, headline 84px. Stripe moves totop: 30%, 60px tall, ticker 28px. Hide “Est. 2016” in the meta line. Section headings 60px. Day tabs shrink to 11px with tight tracking; all seven stay on one row. Filters wrap, each 50% wide. Hide the level, coach and spaces columns; keep time, class and Book. Coaches 1 column, portraits 160px. Form 1 column. The sticky button spans the width with 20px side gaps, no offset shadow. - Every grid uses
minmax(0, 1fr). The hero hasoverflow: hiddenso the rotated stripe never causes horizontal scroll.
Acceptance checklist
Always
- Every corner is square. Borders are 2px. No soft shadows.
- One accent colour. Everything else is black, off-white, or grey.
- The hero headline is the largest thing on the page, set in a condensed display face.
- The timetable has seven real tabs with arrow-key support and two filters.
- The class count is a live region and handles singular and plural.
- Zero-space classes show a waitlist state; no match shows an empty-state row.
- The billing toggle uses
aria-pressedand rewrites price and unit together. - The trial form shows per-field errors, focuses the first one, and confirms in a status line.
- The sticky CTA hides over the hero and over the form, and is not focusable when hidden.
- Focus rings are 3px orange with 3px offset.
- Reduced motion stops the ticker and the button press.
- At 390px there is a menu button, and no horizontal scroll.
This demo
- Brand “Graftworks”, headline “Hit / harder. / Lift heavy.” with “harder.” in orange.
- Ticker: “No mirrors · No contracts · Gloves on the house · First class free”.
- Side box: “41 classes a week”, buttons “Book free class” and “See timetable”.
- Monday shows 5 classes from 06:30 Conditioning to 19:30 Open Boxing.
- Tiers Day shift £39, Unlimited £69 (orange), Fight camp £119. Annual £390 / £690 / £1,190.
- Coaches Dee Achebe, Marcus Vale, Ines Roca, Rob Keane.
Implementation notes
One data array drives the timetable. Each class is [time, name, level, coach, days[]]. Filter, sort by time, render. Do not hard-code seven tables.
function draw() {
const list = CLASSES
.filter(c => c.days.includes(day))
.filter(c => !level.value || c.level === level.value)
.filter(c => !coach.value || c.coach === coach.value)
.sort((a, b) => a.time.localeCompare(b.time));
rows.innerHTML = list.length
? list.map(rowHTML).join('')
: '<tr><td colspan="6" class="empty">No classes match. Clear a filter or pick another day.</td></tr>';
count.textContent = list.length + (list.length === 1 ? ' class' : ' classes');
}
Roving tabindex on the day tabs.
function select(t) {
tabs.forEach(x => { const on = x === t; x.setAttribute('aria-selected', on); x.tabIndex = on ? 0 : -1; });
day = +t.dataset.d; draw();
}
tabs.forEach((t, i) => t.addEventListener('keydown', e => {
const k = { ArrowRight: 1, ArrowLeft: -1 }[e.key];
if (k) { const n = tabs[(i + k + 7) % 7]; select(n); n.focus(); }
}));
Sticky CTA that knows when to leave. Watch the hero and the trial section. Hide when either is visible:
const seen = new Map();
new IntersectionObserver(es => {
es.forEach(x => seen.set(x.target.id, x.isIntersecting));
const off = seen.get('h') || seen.get('trial');
sticky.classList.toggle('off', !!off);
sticky.setAttribute('aria-hidden', !!off);
off ? sticky.setAttribute('tabindex', '-1') : sticky.removeAttribute('tabindex');
}).observe(hero);
Observe #trial with the same observer. Start the button with .off in the HTML so the first frame has no duplicate CTA.
Form field named “name”. form.name returns the form’s own name attribute, not the input. Read fields through form.elements.name. This bug fails silently.
The stripe. Absolute, wider than the hero, rotated, behind the type:
.hero { position: relative; overflow: hidden; }
.stripe { position: absolute; z-index: 1; left: -10%; right: -10%; top: 58%; height: 96px;
background: var(--orange); transform: rotate(-7deg); display: flex; align-items: center; }
.stripe p { white-space: nowrap; animation: run 38s linear infinite; }
@keyframes run { to { transform: translateX(-50%); } }
Coach portraits are a repeating-linear-gradient(-55deg, #0c0c0c 0 14px, #1f1f1f 14px 28px) block, 110px Anton initials centred, and a 14px orange bar from the left edge at 60% width, 18px from the bottom.
Common mistakes:
- Rounded buttons or 8px cards. Radius is 0 everywhere.
- A second accent (yellow, red, neon green). There is only orange.
- Stock photos of athletes. Initials and stripes stand in.
- Gradient overlays or glow on the hero. Flat black and one flat stripe.
- A timetable that is a picture of a table. It must filter.
- A pill-shaped billing switch. It is two square buttons in a 2px box.
- The sticky button covering the form’s submit button on mobile.
- Letting the rotated stripe widen the page at 390px.
Rebuild order:
- Tokens, fonts,
* { border-radius: 0 }, sticky header. - Hero headline, side box, stripe with ticker.
- Timetable tabs, filters, data array, render, empty and waitlist states.
- Tiers with the billing toggle.
- Coach cards.
- Trial form with validation and status.
- Footer and sticky CTA with the observer.
- Breakpoints at 1180, 960 and 640, then the menu toggle.
- Reduced motion, focus pass, 390px overflow check.
Details
- Palette
- Type
- Anton · JetBrains Mono
- Motion
- Subtle motion
- Build
- One session
- Tags
Studied
The F-ListMake the reveal the message. Bars lift off the text, and badges explain the list.
All sourcesOn the shelves