← All Term 3 slide decks TERM 3 · LESSONS 3 + 4 · EXPLAINER Component tour slides ↗

Sample project explainer

How the Opportunities Club
website works.

The Opportunities Club is a one-page website where students choose a topic (Study, Events, Tech, Creative or Volunteering) and see the matching clubs and events. This explainer walks through every file and every component, so you can read the code, change it with confidence, and reuse it in your capstone.

1. The files and what each one does

All of the code lives in term3/lesson-activities/lesson-3-opportunities-club/.

FileJobLesson
lesson1.htmlThe starting draft: a header with a nav, a heading and a short paragraph. No CSS file linked yet.1
lesson1.cssFirst styles for the draft: a blue nav bar, Flexbox for the menu and the Roboto font.2
index.htmlThe finished page: landmarks, the finder form, eight opportunity cards, About and Join sections.3
style.cssAll the design and layout for index.html, written mobile-first in numbered sections.3
app.jsShows or hides cards when a topic is chosen and writes a results message.4
README.mdFacilitator guide: which file fits which lesson block, extensions and the debug challenge answer key.All

Heads up: the cards point to pictures in an images/ folder (for example images/coding-club.svg). That folder is not in the repository yet, so the browser shows each picture’s alt text instead. The layout still works. Add your own images with the same file names, or change each src.

The README also mentions a debug-challenge.js handout and a v2-extended version; these are planned extras and are not in this folder yet.

2. How to open and run it

  1. Download or clone the repository, then open index.html in any browser. There is nothing to install.
  2. The Josefin Slab font loads from Google Fonts, so you need internet for it. Without internet the page falls back to "Segoe UI", system-ui or sans-serif.
  3. Open DevTools (F12 or right-click → Inspect) to use the Console, the Elements panel and the device toolbar.
  4. In CodePen: paste index.html (only what is inside <body>), style.css and app.js into the three panels. Local images will not load there.

3. From first draft to finished page

lesson1.html is what a page might look like after Lesson 1. Comparing it with index.html shows the upgrades Lesson 3 teaches:

Draft (lesson1.html)Finished (index.html)Why it is better
No lang, no charset, no viewport tag<html lang="en">, <meta charset>, <meta name="viewport">Correct pronunciation for screen readers, correct characters, and phones stop pretending to be desktops.
Heading inside <header>; no <main>Header holds only the logo and nav; content sits in one <main>Landmarks let assistive technology jump straight to the content.
Links with empty href=""Links to #finder, #about, #joinEach link now goes somewhere real on the page.
Fixed padding: 20px, colours typed each timeCustom properties, rem, clamp() and min()One change updates the whole site, and sizes adapt to the screen and the user’s font setting.
No skip link, no focus stylesSkip link and :focus-visible ringKeyboard users can see where they are and skip the menu.

4. The page skeleton

Before looking at any single part, look at the outline. Every visible area is inside a landmark: an element that tells the browser what that area is for.

<body>
  <a class="skip-link" href="#main">Skip to main content</a>   ← component 1
  <header class="site-header"> logo + <nav> </header>       ← component 2
  <main id="main">
    <div class="hero"> h1 + lead </div>                    ← component 3
    <section id="finder">
      <form id="filter-form"> … </form>                     ← component 4
      <p id="results-message" role="status"></p>           ← component 5
      <div id="cards" class="card-grid">                   ← component 6
        <article class="card"> … </article> × 8           ← component 7
      </div>
    </section>
    <div class="container two-col section"> #about + #join </div>  ← component 8
  </main>
  <footer class="site-footer"> … </footer>                 ← component 9
  <script src="app.js" defer></script>
</body>

Two layout helpers appear all over the page:

The <script> tag uses defer, which means “download now, but run after the HTML has been read”. That guarantees the form and cards exist when app.js looks for them.

5. The components, one by one

Component 1: Skip link

What it is: the very first link on the page, “Skip to main content”. Why: keyboard users would otherwise have to Tab through every menu link on every page.

.skip-link { position: absolute; left: 1rem; top: -4rem; z-index: 10; … }
.skip-link:focus { top: 1rem; }

It is parked above the top of the screen (top: -4rem). When it receives focus, it slides into view. Its href="#main" matches <main id="main">. Try it: reload the page and press Tab once.

Component 2: Header and navigation

HTML: a <header> containing the logo text and a <nav aria-label="Main"> with a list of three links. A list is used because a menu is a list of choices, and screen readers announce “list, 3 items”.

CSS (Flexbox):

.header-inner { display: flex; flex-direction: column; align-items: center; gap: 0.5rem 2rem; }
.site-nav ul  { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.25rem 1.25rem; list-style: none; }
.site-nav a   { display: inline-block; padding: 0.6rem 0.25rem; }  /* bigger tap target */

@media (min-width: 40rem) {
  .header-inner { flex-direction: row; justify-content: space-between; }
}

On phones the logo sits above the menu. Once the screen is at least 40rem wide, the media query switches to a row: logo on the left, menu on the right. Because the header is dark blue, .site-header :focus-visible changes the focus ring to yellow so it stays visible.

Component 3: Hero

HTML: the page’s only <h1> and a lead paragraph that says what the site is for.

.hero h1 { max-width: 18ch; font-size: clamp(2rem, 1.2rem + 4vw, 3.5rem); }
.lead    { max-width: 38rem; font-size: clamp(1.0625rem, 1rem + 0.5vw, 1.25rem); }

clamp(min, ideal, max) makes the heading grow smoothly with the screen but never smaller than 2rem or larger than 3.5rem. 18ch is about 18 characters wide, which keeps the big heading to a few short lines.

Component 4: Finder form

HTML: a <form> with one labelled <select> and a submit <button>.

CSS (Flexbox with wrapping):

.filter-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 1rem; }
.field       { display: flex; flex-direction: column; flex: 1 1 14rem; }
select       { min-height: 2.75rem; font: inherit; border: 2px solid var(--field-border); }

flex: 1 1 14rem reads as “grow, shrink, and aim for 14rem”. When there is room the dropdown and button share a row; when there is not, the button wraps underneath. font: inherit is needed because form controls ignore the page font by default. min-height: 2.75rem (about 44px) gives a comfortable touch target.

Component 5: Status message

<p id="results-message" class="status" role="status"></p>

It starts empty; app.js writes text into it, such as “Showing 2 of 8 opportunities.” role="status" tells screen readers to announce the change politely, without interrupting. In CSS, min-height: 1.6em keeps a line of space reserved so the cards below do not jump when the text appears.

Component 6: Card grid

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
  gap: 1rem;
}

Read it from the inside out:

Result: one column on a phone, two on a tablet, three or more on a laptop, with no media query at all.

Component 7: Card (the reusable part)

<article class="card" data-category="tech">
  <img src="images/coding-club.svg" alt="Two students beside a laptop showing lines of code" width="400" height="240">
  <div class="card-body">
    <div class="card-meta"><span class="chip">Tech</span></div>
    <h3>Coding club</h3>
    <p>Build small programs and websites with a friend.</p>
    <p class="card-when">Thursdays, after classes</p>
  </div>
</article>
<article>
Each card makes sense on its own, which is exactly what article means.
data-category="tech"
A custom data attribute. CSS uses it to pick the card’s colours, and JavaScript uses it to decide whether to show the card. It must match an <option value> exactly, including lowercase letters.
<img alt width height>
The alt text describes the picture for anyone who cannot see it. width and height let the browser reserve space before the image loads, so the page does not jump.
.card-body
Holds all the text, so the picture can reach the card’s edges while the words keep their padding.
.chip
The small rounded topic label. border-radius: 999px makes a pill shape.
<h3>
Headings stay in order: h1 (page) → h2 (Opportunity finder) → h3 (each card).
.card-when
margin-top: auto pushes the “when” line to the bottom of the card, so every card in a row lines up.

CSS highlights:

.card { --accent: var(--line); --accent-tint: var(--paper);
        display: flex; flex-direction: column; border-radius: 0.75rem; overflow: hidden; }
.card-body { display: flex; flex: 1; flex-direction: column; gap: 0.5rem; padding: 1.25rem; }
.card[data-category="tech"] { --accent: var(--tech); --accent-tint: var(--tech-tint); }
.chip { background: var(--accent-tint); border-radius: 999px; }

Each card sets its own local custom properties. The attribute selector .card[data-category="tech"] swaps in the Tech colours, and the chip and image background use them automatically. overflow: hidden clips the picture to the rounded corners.

Component 8: About and Join

Two <section> elements inside a .two-col wrapper. Each section uses aria-labelledby pointing at its own <h2>, which gives the region a name. The joining steps are an <ol> because they happen in order.

.two-col { display: grid; gap: 2rem; }                 /* one column on phones */
@media (min-width: 600px) {
  .two-col { grid-template-columns: 1fr 1fr; }        /* two equal columns */
}
.join-box { padding: 1.5rem; background: var(--study-tint); border-radius: 0.75rem; }

A <footer> landmark with one line of text, centred, with a light top border. Simple on purpose: this is where contact links or credits would go on a real site.

6. style.css, section by section

The file is mobile-first: the normal rules describe the phone layout, and @media (min-width: …) blocks add changes for bigger screens. The numbered comments in the file match this list.

#SectionKey ideas
0FontsJosefin Slab is loaded by <link> tags in the HTML head, with fallbacks listed in font-family.
1Design tokens:root custom properties such as --ink, --focus, and a colour plus tint for each category. Change one here and it changes everywhere.
2Base stylesbox-sizing: border-box; body font in rem; img { max-width: 100%; height: auto }; [hidden] { display: none !important }; long links wrap with overflow-wrap: anywhere.
3Accessibility helpersThe skip link and a :focus-visible outline. Never remove outlines without replacing them.
4Layout helpers.container with min(), .section with clamp().
5Header + navFlexbox column that becomes a row at 40rem.
6HeroFluid heading with clamp() and a ch line length.
7Finder formWrapping Flexbox, labelled fields, a 2.75rem tall button, and the .status line.
8Cards + imagesauto-fit Grid, flex-column cards, attribute selectors for category colours, chips.
9About / JoinGrid that becomes two columns at 600px.
10FooterCentred text with a top border.
11Motion preferencesSmooth scrolling only inside @media (prefers-reduced-motion: no-preference), so people who turn off animations are respected.

Why [hidden] needs !important: the browser hides hidden elements with display: none, but .card { display: flex } is more specific and would win, so hidden cards would still show. The !important rule makes “hidden” always mean hidden, which is what app.js relies on.

A few classes, such as .button--quiet, .chip--featured and input[type="search"], are styled but not used on this page yet. They are ready for extensions like a search box or a “Featured” label.

7. app.js, step by step

The cards are already written in HTML, so the page still works if JavaScript fails. The script has one job: show or hide cards when someone picks a topic.

Step 1: Find the elements (variables)

const form = document.querySelector("#filter-form");
const topicSelect = document.querySelector("#topic");
const message = document.querySelector("#results-message");
const cards = document.querySelectorAll(".card");

querySelector finds the first element that matches a CSS selector; querySelectorAll finds all of them and returns a list. const is used because these references never change.

Step 2: A function with parameters and a return value

function cardMatches(card, topic) {
  if (topic === "all") {
    return true;
  }
  return card.dataset.category === topic;
}

Inputs: one card and a topic. Output: true (show it) or false (hide it). data-category in HTML is read as card.dataset.category in JavaScript. === checks that two values are exactly the same, so "Tech" and "tech" do not match.

Step 3: A loop and a decision

function showTopic(topic) {
  let visibleCount = 0;
  for (const card of cards) {
    if (cardMatches(card, topic)) {
      card.hidden = false;
      visibleCount = visibleCount + 1;
    } else {
      card.hidden = true;
    }
  }
  if (visibleCount === 0) {
    message.textContent = "No opportunities in this topic yet. Try another topic!";
  } else {
    message.textContent = "Showing " + visibleCount + " of " + cards.length + " opportunities.";
  }
}

let is used for visibleCount because it changes. for…of runs the block once for each card. Setting card.hidden adds or removes the HTML hidden attribute. textContent writes plain text (safer than innerHTML because nothing is treated as code).

Step 4: Listen for the form (event)

form.addEventListener("submit", function (event) {
  event.preventDefault();
  showTopic(topicSelect.value);
});

A form normally reloads the page when submitted. preventDefault() stops that so the script can update the page instead. topicSelect.value is the value of the chosen option.

Step 5: Start

showTopic("all");

Runs once when the page loads, so every card is visible and the message already says “Showing 8 of 8 opportunities.”

8. What happens when you press “Show results”

  1. You choose “Tech” in the dropdown. Its value is "tech".
  2. You click the button (or press Enter). The form fires a submit event.
  3. The listener stops the page reload and calls showTopic("tech").
  4. showTopic loops over all 8 cards and asks cardMatches(card, "tech") for each one.
  5. The 2 Tech cards get hidden = false; the other 6 get hidden = true. CSS [hidden] removes them from the layout.
  6. The grid reflows the remaining cards automatically.
  7. The status message becomes “Showing 2 of 8 opportunities.” and screen readers announce it.

9. Accessibility checklist

Every item below is already built into the page. Use it as a checklist for your own project.

Test it: use only the keyboard (Tab, Shift+Tab, Enter, arrow keys in the dropdown); zoom to 200%; set the device toolbar to 320px wide.

10. Adapting it for your capstone

  1. Change a card: copy a whole <article class="card">, then change the heading, text, image, alt text and data-category.
  2. Add a topic: add an <option value="yourtopic"> to the dropdown, then add a .card[data-category="yourtopic"] rule in style.css with two new colour tokens in :root.
  3. No JavaScript changes needed: app.js works with any number of cards and any topic names.

The same pattern fits all three capstones:

Stretch goal: move the card content into a JavaScript array of objects and build the cards from the data. That is the Lesson 4 “turn capstone information into structured data” objective.

11. Common mistakes and how to fix them

What you seeLikely causeFix
A card never appears for its topicdata-category and <option value> differ (event vs events, or Tech vs tech)Make them exactly the same, all lowercase.
A new card has no colourNo .card[data-category="…"] rule for that categoryAdd the rule and its colour tokens.
Page reloads and the filter resetsevent.preventDefault() missingPut it back as the first line of the listener.
Nothing happens; Console shows Cannot read properties of nullAn id in querySelector does not match the HTML, or defer was removedCheck spelling of ids and keep defer on the script tag.
Hidden cards still showThe [hidden] rule was deletedRestore [hidden] { display: none !important; }.
Broken image iconThe file is not in images/ or the name is misspelledCheck the path; the alt text will show until it is fixed.
Page scrolls sideways on a phoneA fixed width or missing box-sizingUse max-width, min() and keep the box-sizing rule.

12. Facilitator notes

Lesson blockUse
L3 Connect (0–10)Open index.html and resize the window. Ask what moves, wraps or shrinks.
L3 Learn (10–30)style.css sections 5, 7 and 8, and the two @media blocks. Component tour slides 5–11.
L3 Guided build (30–50)Landmarks, the labelled form and one card with alt text. Slides 3, 7 and 10.
L3 DevTools (50–60)Grid overlay on .card-grid, inspect .card-body padding, device toolbar at 320px.
L4 Learn (10–30)app.js steps 1–3. Slides 13–14.
L4 Guided practice (30–50)Step 4 and the “Try it yourself” list at the bottom of app.js. Slide 15.
Capstone checkpointTeams copy a card and change the content and category. Slide 16.

13. Glossary

Component
A self-contained part of a page, such as the header, a form or a card, with its own HTML and CSS.
Landmark
An element such as header, nav, main or footer that tells assistive technology what an area is for.
Mobile-first
Writing the small-screen styles first, then adding min-width media queries for bigger screens.
Media query
A CSS block, like @media (min-width: 40rem), whose rules only apply when the condition is true.
Flexbox
A one-direction layout (a row or a column) for lining items up and spacing them.
Grid
A two-direction layout of rows and columns.
Custom property
A CSS variable such as --focus, used with var(--focus).
rem
A size relative to the root font size, which follows the user’s browser setting.
clamp() / min()
CSS functions that pick a size within limits, so things resize smoothly.
data-* attribute
Your own named attribute for storing information on an element, read in JavaScript through dataset.
Event listener
Code that waits for something to happen, such as a form submit, then runs a function.
Parameter / return value
The inputs a function receives, and the answer it gives back.