What will the finished profile page do?
You built a profile page in the HTML course and styled it in the CSS course. Now it gets four small behaviours, each one a pattern you will reuse on almost every site: a dark-mode button that remembers the choice, buttons that filter your projects by tag, a contact form that explains problems in friendly words, and a button that copies your email address.
Each feature is short, around ten to twenty lines. The work is in the details: the page has to make sense before the script runs, every control has to work with a keyboard, and screen reader users have to hear the same state that sighted visitors see. Build the features one at a time and test each before starting the next.
Start with a script.js linked at the end of the <body> or in the <head> with defer, so the elements exist when the code looks for them. Keep each feature in its own block with a comment above it, so you can find and fix them later.
<script src="script.js" defer></script>

How do you build a dark-mode toggle that remembers?
Put the colours in CSS and let the script flip one class. Define your colours as custom properties on body and redefine them under body.dark. The script then only needs document.body.classList.toggle("dark"), which returns true when dark mode is now on.
The button is an on/off control, so give it aria-pressed="false" in the HTML and update it from the value toggle returns. Save the choice with localStorage.setItem("theme", ...). On load, read the same key and apply the class before anything else. Use one constant for the key name; a typo between the save and the read is the most common bug in this feature, and it fails without any error.
Visitors who have not chosen yet can follow their system setting. matchMedia("(prefers-color-scheme: dark)").matches tells you their preference; use it only when nothing is saved.
const THEME_KEY = "theme";
const themeButton = document.querySelector("#theme");
const saved = localStorage.getItem(THEME_KEY);
const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
function setDark(isDark) {
document.body.classList.toggle("dark", isDark);
themeButton.setAttribute("aria-pressed", String(isDark));
}
setDark(saved ? saved === "dark" : prefersDark);
themeButton.addEventListener("click", () => {
const isDark = !document.body.classList.contains("dark");
setDark(isDark);
localStorage.setItem(THEME_KEY, isDark ? "dark" : "light");
});How does the project filter work?
Each project item carries its tags in a data attribute, data-tags="html css", and each filter button carries the tag it selects, data-filter="css". Data attributes keep the information in the HTML, where you edit your projects anyway, and dataset reads them from the script: button.dataset.filter gives "css".
When a button is clicked, loop over every project. Split its tags into an array and decide whether it should be hidden: hide it unless the filter is "all" or the tags include the filter. Setting the hidden property keeps the element in the page, so the next filter can show it again; removing it would lose it for good.
Then update the buttons. These filters behave like a set of options where one is active, so reset every button to aria-pressed="false" and set the clicked one to "true". Style the active one with an attribute selector, [aria-pressed="true"], so the visual state and the announced state come from the same attribute and cannot disagree. Wrap the buttons in an element with role="group" and an aria-label so screen readers announce them as one set.
project.hidden = tag !== "all" && !tags.includes(tag);
buttons.forEach((other) => {
other.setAttribute("aria-pressed", String(other === button));
});How should the page tell people how many projects match?
Filtering changes content away from the button, so a screen reader user does not hear anything happen. Add a status line with aria-live="polite" to the HTML and update it after each filter: "Showing 2 of 4 projects." Count with [...projects].filter((p) => !p.hidden).length.
Keep the message short and only update it on a click. If nothing matches, say so plainly, "No projects with this tag yet", so the empty space has an explanation for everyone.
const shown = [...projects].filter((p) => !p.hidden).length;
status.textContent = `Showing ${shown} of ${projects.length} projects`;How do you add friendly validation to the contact form?
Keep the HTML attributes that describe the rules, such as required and type="email", and add novalidate to the form so your own messages replace the browser's pop-ups. In the submit listener, call event.preventDefault() first. Without it the browser sends the form and reloads the page, and your message disappears before anyone reads it.
Check each field and write a message next to it: "Add an email with an @ so I can reply." Tell the visitor what to do, never just "Invalid input". Link each message to its field with aria-describedby so a screen reader reads it when the field is focused, and set aria-invalid="true" on fields with a problem. Move focus to the first field that needs fixing, so keyboard users do not have to hunt for it. Clear the messages when the problem is fixed, and when everything is valid show a thank-you in a live region.
The form's checkValidity() and each field's validity object give you the browser's own rules, so you do not have to write an email pattern by hand.
form.addEventListener("submit", (event) => {
event.preventDefault();
emailError.textContent = "";
email.removeAttribute("aria-invalid");
if (!email.validity.valid) {
emailError.textContent = "Add an email with an @ so I can reply.";
email.setAttribute("aria-invalid", "true");
email.focus();
return;
}
formStatus.textContent = "Thanks. I will reply within two days.";
});How do you build a copy-email button with a fallback?
navigator.clipboard.writeText(text) copies text and returns a Promise. It is available in current browsers on HTTPS pages and on localhost, and it needs a real click to run. It can still fail: the page may be on plain HTTP, embedded in a frame that blocks it, or the browser may refuse. Always plan for that.
Use async and await with try and catch. On success, say "Email copied" in a live region. On failure, show a helpful fallback: "Copy failed. Select the address and copy it yourself." Keep the email address visible as text (and as a mailto: link) so the button is a convenience on top of something that already works without it.
Check for the API before relying on it: if (navigator.clipboard). A button that silently does nothing is worse than no button.
copyButton.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText("sam@example.com");
copyStatus.textContent = "Email copied";
} catch (error) {
copyStatus.textContent = "Copy failed. Select the address and copy it yourself.";
}
});How do you check everything works with a keyboard?
Put the mouse away and go through the page with Tab, Shift+Tab, Enter and Space. Each filter button, the theme button, every form field and the copy button should be reachable in a sensible order and show a visible focus ring. Because they are real <button> elements, Enter and Space already work; if any control needs extra key handling, it is probably the wrong element.
Submit the form with an empty email and confirm focus lands on the field with the message. Use the Events lens in PlainFinch to confirm each key press reached the right listener, and Changes to see which attributes flipped.
What is on the final checklist?
Go through this list before you call the page finished. Each item is quick to test, and each one catches a real problem that shows up in student projects and AI-generated pages.
- The page reads well with JavaScript turned off: all projects show, the email is visible.
- Dark mode survives a reload and follows the system setting when nothing is saved.
- Exactly one filter button has
aria-pressed="true"at any time, and All brings everything back. - A live region announces how many projects are showing.
- The form never reloads the page; every message says what to do and sits next to its field.
- Copy email works on HTTPS and shows the fallback message when it fails.
- Every control works with the keyboard and has a visible focus style.
- The Console shows no errors and no leftover debugging logs.
- No
innerHTMLwith visitor input, no emptycatch, no keys or secrets in the script.
Field notes
- Keep colours in CSS and let the script toggle one class for dark mode.
- Data attributes like
data-tagsanddata-filterconnect buttons to projects. aria-pressedtells screen readers which filter button is active.- Test the whole page with the keyboard using Tab, Enter and Space.
Easy to get wrong
Removing elements loses them, so the next filter cannot bring them back. hidden keeps them in the page and out of sight and out of the accessibility tree.
Without preventDefault, the browser sends the form and reloads, and your messages vanish.
Keys are case-sensitive. A mismatch gives null with no error. One constant keeps them identical.
A div cannot be reached or pressed with the keyboard. A button can, and aria-pressed announces the state.
Questions people ask
Should I use aria-pressed or aria-current for filters?
Use aria-pressed for buttons that switch a filter on the same page. aria-current is for links, such as the current page in a navigation menu.
Why does dark mode flash light for a moment on reload?
The page paints before your script runs. Load a tiny script in the <head> that reads the saved theme and sets the class, or use color-scheme and prefers-color-scheme in CSS for the default.
Why does copy to clipboard fail in my preview?
The clipboard API needs a secure page and a real user click, and some embedded previews block it. That is exactly why you need the fallback message.
Can an AI build this page for me?
It can draft it quickly. Then use the final checklist: AI drafts often use divs as buttons, skip preventDefault, forget aria-pressed, or have no fallback when copying fails.