Module 9: Real-World Usage
Building Common Components: Navbar, Card, Button System and Dialog
Complete, reusable implementations of the components almost every site needs, built with the layout, variable and state techniques from earlier modules.
Everything so far has been individual techniques. Real work is combining them into components that behave well at any width, in any container, with a keyboard or a mouse. This lesson builds four components you will write over and over, and explains the decisions inside each one.
A shared base#
:root {
--brand: #0b5fff;
--brand-dark: #0847c4;
--text: #111827;
--muted: #4b5563;
--surface: #ffffff;
--border: #e5e7eb;
--radius: 10px;
--space: 1rem;
}
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font: 1rem/1.6 system-ui, sans-serif; color: var(--text); }
img { max-width: 100%; display: block; }
Responsive navbar#
Requirements: logo on the left, links on the right, and on small screens the links collapse into a menu toggled by a button.
<header class="site-header">
<nav class="nav" aria-label="Main">
<a class="nav__logo" href="/">Acme</a>
<button class="nav__toggle" aria-expanded="false" aria-controls="menu">Menu</button>
<ul class="nav__links" id="menu">
<li><a href="/features">Features</a></li>
<li><a href="/pricing">Pricing</a></li>
<li><a href="/docs">Docs</a></li>
<li><a class="btn" href="/signup">Sign up</a></li>
</ul>
</nav>
</header>
.site-header { position: sticky; top: 0; background: var(--surface); border-bottom: 1px solid var(--border); z-index: 100; }
.nav {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space);
max-width: 70rem;
margin-inline: auto;
padding: 0.75rem var(--space);
}
.nav__logo { font-weight: 700; font-size: 1.25rem; text-decoration: none; color: inherit; margin-right: auto; }
.nav__links { display: none; width: 100%; flex-direction: column; gap: 0.25rem; list-style: none; margin: 0; padding: 0; }
.nav__links.is-open { display: flex; }
.nav__links a { display: block; padding: 0.6rem 0; color: inherit; text-decoration: none; }
.nav__links a:hover { color: var(--brand); }
@media (min-width: 768px) {
.nav__toggle { display: none; }
.nav__links { display: flex; width: auto; flex-direction: row; align-items: center; gap: 1.5rem; }
}
const toggle = document.querySelector(".nav__toggle");
const menu = document.querySelector("#menu");
toggle.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
toggle.setAttribute("aria-expanded", String(open));
});
Why it works:
- Mobile first. The base state hides the links. The media query turns on the desktop layout, and no overrides are needed to undo anything.
margin-right: autoon the logo pushes everything else to the right, as in the Flexbox lesson.flex-wrap: wraplets the link list wrap onto its own row (width: 100%) on mobile, below the logo and button.aria-expandedtells screen reader users whether the menu is open. The state lives in HTML attributes, and the CSS handles appearance through theis-openclass.- Sticky header with a
z-indexfrom a defined scale, so content scrolls under it.
Card#
<article class="card">
<img class="card__image" src="trail.jpg" alt="Hikers on a ridge at sunrise" width="800" height="500">
<div class="card__body">
<h3 class="card__title"><a class="card__link" href="/trails/ridge">Ridge Trail</a></h3>
<p class="card__text">A moderate 8 km loop with wide views and a short scramble near the top.</p>
</div>
<footer class="card__footer">
<span>8 km</span>
<span>3 h</span>
</footer>
</article>
.card {
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden; /* clips the image to the rounded corners */
position: relative;
transition: box-shadow 150ms ease, transform 150ms ease;
}
.card:hover { box-shadow: 0 8px 24px rgb(0 0 0 / 0.1); transform: translateY(-2px); }
.card__image { width: 100%; aspect-ratio: 16 / 10; object-fit: cover; }
.card__body { padding: var(--space); flex: 1; } /* flex: 1 pushes the footer down */
.card__title { margin: 0 0 0.5rem; font-size: 1.15rem; }
.card__text { margin: 0; color: var(--muted); }
.card__footer { display: flex; justify-content: space-between; padding: 0.75rem var(--space); border-top: 1px solid var(--border); font-size: 0.875rem; color: var(--muted); }
/* make the whole card clickable with a single real link */
.card__link { color: inherit; text-decoration: none; }
.card__link::after { content: ""; position: absolute; inset: 0; }
.card:focus-within { outline: 3px solid rgb(11 95 255 / 0.4); outline-offset: 2px; }
Notable techniques:
flex: 1on the body makes all cards in a row end at the same place, whatever their text length.- The stretched link. The link's
::afteris absolutely positioned across the whole card (the card isposition: relative), so a click anywhere hits the link. You keep one real link, one tab stop, and a clean accessible name. Nesting a whole card inside anais worse for screen readers. - Card grids come from the parent:
.card-grid { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr)); }
The card itself has no width. The grid decides its width, so the same card works in a sidebar or a gallery.
Button system#
.btn {
--btn-bg: var(--brand);
--btn-fg: #fff;
--btn-border: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 2.75rem;
padding: 0 1.25rem;
border: 1px solid var(--btn-border);
border-radius: 8px;
background: var(--btn-bg);
color: var(--btn-fg);
font: inherit;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: background-color 120ms ease, transform 120ms ease;
}
.btn:hover { --btn-bg: var(--brand-dark); }
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 3px solid rgb(11 95 255 / 0.4); outline-offset: 2px; }
.btn:disabled, .btn[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; pointer-events: none; }
.btn--secondary { --btn-bg: transparent; --btn-fg: var(--brand); --btn-border: currentColor; }
.btn--secondary:hover { --btn-bg: rgb(11 95 255 / 0.08); }
.btn--danger { --btn-bg: #b42318; }
.btn--small { min-height: 2.25rem; padding: 0 0.9rem; font-size: 0.875rem; }
The variant classes only change a few custom properties, which is the component API pattern from the variables lesson. Setting --btn-bg inside :hover works because the background declaration reads the variable at the element, so changing the variable there changes the result.
Modal dialog with the native element#
The dialog element gives you focus trapping, the Escape key, a backdrop and top-layer rendering without any positioning hacks or z-index.
<button class="btn" id="open">Delete project</button>
<dialog class="dialog" id="confirm" aria-labelledby="dlg-title">
<form method="dialog">
<h2 id="dlg-title">Delete this project?</h2>
<p>This cannot be undone.</p>
<div class="dialog__actions">
<button class="btn btn--secondary" value="cancel">Cancel</button>
<button class="btn btn--danger" value="confirm">Delete</button>
</div>
</form>
</dialog>
.dialog {
width: min(28rem, calc(100% - 2rem));
padding: 1.5rem;
border: 0;
border-radius: 14px;
box-shadow: 0 24px 64px rgb(0 0 0 / 0.3);
}
.dialog::backdrop { background: rgb(17 24 39 / 0.55); backdrop-filter: blur(2px); }
.dialog__actions { display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1.5rem; }
document.querySelector("#open").addEventListener("click", () => {
document.querySelector("#confirm").showModal();
});
showModal() places the dialog in the top layer, above everything else, regardless of any stacking contexts. A form with method="dialog" closes it when a button is pressed and returns the button's value. Compare that to the fixed overlay approach from the positioning lesson, which breaks when an ancestor has a transform and needs manual focus handling.
Common mistakes#
- Building components at fixed widths instead of letting the container decide.
- Wrapping the whole card in a link, which creates huge accessible names.
- Hover-only dropdown menus that cannot be reached by keyboard or touch.
- Toggling a class without updating
aria-expandedor equivalent state. - Hand-rolled modals without focus trapping or Escape support.
- Overriding component styles by context (
.sidebar .btn) instead of using modifiers. - Forgetting the disabled and focus states.
Practice#
- Build the navbar and test it by resizing from 320px up. Make sure the menu is reachable by keyboard at every width.
- Build a grid of five cards with different text lengths and make sure all of them have equal height, with footers aligned at the bottom.
- Add an
--btn-sizevariable to the button system and create three sizes with it. - Add a second dialog to the page, close it with the Escape key, and confirm focus returns to the button that opened it.