A practical, technique-by-technique reference for solving the recurring problems that come up when building web front ends with plain HTML and CSS. Rather than a single narrative, this is a “playbook” of independent plays — each one a self-contained pattern, recipe, or trick that you can pull out when the matching problem arises. Every technique in this reference is built with no server-side code and no client-side scripting — the goal is to show what pure HTML and CSS can achieve on their own, and where their limits are.
Table of Contents
- Module 1: Establishing Conventions, Tools, and the Project Repository
- Module 2: Structuring CSS Like Proper Code with Sass
- Module 3: Three Ways to Center Elements
- Module 4: Building a Collapsible List Without Script
- Module 5: Making HTML Accessible to Everyone
- Module 6: Five Ways to Make Text More Readable
- Module 7: Working with Custom Fonts
- Module 8: Filtering User Input with Simple HTML
- Module 9: Working Effectively with Semantic HTML
- Module 10: Knowing What Works in Which Browsers
- Module 11: Using a UI Framework Like Bootstrap
- Module 12: Creating Compelling Animations Using Only CSS
- Module 13: Reducing HTTP Requests with CSS Image Sprites
- Module 14: Creating Powerful Graphics with SVG in HTML
- Module 15: Controlling Capitalization with CSS
- Module 16: Making Links Target Different Media and Devices
- Module 17: Replacing Zombie Tags with Something Better
- Module 18: Restricting File Types in Upload Inputs
- Module 19: Selecting Multiple Files with Upload Inputs
- Module 20: Creating Simple Data Visualization with HTML and CSS Only
- Module 21: Taking Control Over Mobile Presentations with Media Targets
- Summary
Module 1: Establishing Conventions, Tools, and the Project Repository
Purpose of a playbook approach
HTML and CSS form such a broad set of topics that the most productive way to build mastery is to accumulate a supply of separate, well-understood techniques — plays you can reach for as a situation demands. This has two benefits: it steadily builds real capability, and it makes the process of building that capability bank more enjoyable, because each play is a self-contained, solvable problem.
Tooling constraints, and why they matter
All of the work in this reference is done with the simplest possible tools:
- A plain text editor (no IDE, no Eclipse, no Visual Studio) — this keeps the material as close to the raw code as possible, without any tooling assistance obscuring what is really happening.
- Chromium-based browsers (any of the major Chromium browsers) for viewing results. Since these browsers share the same rendering engine, there is little practical difference between them for this material. Other engines (Firefox, and Safari on macOS) are discussed where their behavior diverges.
- A minimal static web server. Any simple static file server works, as long as it serves files without modification.
The “no server-side code” constraint
One deliberate constraint used throughout is that there is no server-side code at all. In precise terms: nothing executes on the server that recasts or transforms the content of the page being served. The page that reaches the browser is byte-for-byte identical to the file sitting on the server’s disk.
This matters because server-side dynamism is a separate skill from front-end presentation, and mixing them muddies the line between “what’s happening on the server” and “what’s happening on the client.” By removing server-side code from the picture, the markup and styling stay as simple and as clearly attributable as possible.
You might reasonably ask: if we want byte-for-byte fidelity, why use a web server at all? Why not just open the file directly from the local filesystem in the browser? The answer is security. Browsers deliberately block certain behaviors — for example, requests out to the internet, such as loading custom web fonts — when a page is opened directly from the local filesystem rather than served over HTTP. Using even the simplest possible web server sidesteps this class of problem entirely.
The “no script” constraint
The second deliberate constraint is that no client-side script is used to achieve the essential goal of any given technique. This doesn’t mean that scripting couldn’t improve most of these techniques — in fact, most of them probably can be improved with a small amount of script, and where that’s true, it’s called out. But the baseline goal is to understand what pure HTML and CSS can do, with scripting treated as an enhancement layer on top, not a crutch.
A note on code style
The example code intentionally uses inline <style> blocks in the document <head> rather than externally linked, minified, bundled stylesheets. This is a deliberately bad practice for any real project — it is used here purely because it keeps every example self-contained and easy to follow while teaching. In real projects, styles should be externally linked, bundled, minified, and cache-optimized.
A guiding principle throughout: tricks are for kids. Some approaches yield the desired visual result but are counter-intuitive, non-self-documenting, and depend on the quirks of a particular rendering engine and version. Such approaches are brittle, hard to maintain, and often break with the next browser update. The preferred approach is always clean code, using meaningful names and attributes, that implements layout in a way that matches the intent of the people who designed the underlying HTML/CSS features. That produces code that is maintainable and comprehensible to the next developer — very often, that next developer is you, six months later.
flowchart TD
A[Problem to solve] --> B{Is there a clean,<br/>semantic, standards-aligned way?}
B -->|Yes| C[Use it, even if it takes<br/>a few more lines]
B -->|No / only a hacky way exists| D[Consider whether a small<br/>amount of script is warranted]
C --> E[Maintainable, comprehensible code]
D --> E
Module 2: Structuring CSS Like Proper Code with Sass
The problem: CSS has no scoping
Plain CSS gives no way to express nesting or scoping the way most programming languages do. Consider markup like this:
<div class="menu">
<ul class="user-list">
<li>Item</li>
</ul>
</div>
Styled like this:
.menu { /* ... */ }
.menu .user-list { /* ... */ }
.menu .user-list li { /* ... */ }
This works, but nothing stops selectors that started out grouped together from drifting apart in the file as the stylesheet grows — there’s nothing preventing an unrelated selector from being declared in between them, physically separating rules that are conceptually related. This isn’t a rendering problem; it’s a human problem. CSS by itself doesn’t help developers keep related rules grouped in a way that’s easy to comprehend.
It’s tempting to try to express the nesting directly in CSS itself:
.menu {
.user-list {
li { /* ... */ }
}
}
This is not valid CSS (in the classic, pre-nesting specification), and using it will simply cause the browser to silently ignore the rule, losing the intended styling.
The Sass solution: source language, target language, and a transpiler
This is directly analogous to the relationship between TypeScript and JavaScript. TypeScript is a superset language offering strong typing and more traditional object orientation; there’s no native browser support for it, so a transpiler converts TypeScript into equivalent JavaScript before it ships. The transpilation step means you get the benefits of the richer source language while still shipping something the platform (the browser) natively understands.
The same three-part pattern applies to CSS:
| Role | TypeScript scenario | CSS scenario |
|---|---|---|
| Source language (has desired features) | TypeScript | Sass (SCSS) |
| Target language (what the platform runs) | JavaScript | CSS |
| Bridge / transpiler | TypeScript transpiler | Sass preprocessor |
flowchart LR
A[".scss source file<br/>(nesting, variables)"] -->|Sass preprocessor| B[".css output file"]
B --> C[Browser renders page]
A -.->|Source map file| D[Dev tools show<br/>original .scss location]
Installing and running Sass
Sass is installed from its GitHub releases page (a platform-specific download); once unzipped, the executable needs to be added to the system PATH so the sass command is available from a terminal.
Converting a stylesheet to SCSS
- Rename the existing stylesheet’s extension from
.cssto.scss(e.g.,main.css→main.scss). - Rewrite the flat selectors as nested selectors that mirror the HTML structure:
// main.scss
.menu {
.user-list {
li {
// list item styles
}
}
}
- Run the Sass preprocessor, specifying the SCSS file as the source and the desired CSS file as the target:
sass main.scss main.css
This produces the compiled main.css, plus a source map file. The source map lets browser developer tools report that a given rule’s styling originates from the original .scss file and line, even though the browser is only ever loading the compiled CSS — inspecting an element in the dev tools panel will correctly point back at the SCSS source.
A common mistake: forgetting to regenerate
A very frequent mistake is editing the .scss source, but forgetting to re-run the Sass preprocessor. The browser only ever sees the compiled .css output, so if that file isn’t regenerated, changes made in the source are invisible — the first diagnostic step whenever a Sass change “isn’t working” is to check whether the CSS output actually reflects the edit.
A related mistake: after moving a selector’s rules into a nested block, forgetting to delete the old, now-empty top-level selector. Leftover “dead” selectors like this can quietly override properties that were meant to come from the new nested rule, causing regressions like lost padding.
Variables: fixing “magic numbers” and expressing designer intent
Consider a color repeated in more than one place in a stylesheet:
.menu { background-color: navajowhite; }
.content { background-color: navajowhite; }
This has two separate problems:
- Magic numbers. The literal color value is duplicated data. If the design changes, every occurrence has to be found and updated correctly.
- Lost intent. Repeating the literal color name in each place fails to communicate that this color represents a deliberate design decision — a “primary background color for content” — that should probably be reused anywhere that same idea applies.
Sass variables solve both problems:
$primary-bg: navajowhite;
.menu { background-color: $primary-bg; }
.content { background-color: $primary-bg; }
Variables aren’t limited to colors — they work well for fonts, spacing, and any other value that recurs with the same underlying meaning.
Other Sass features (briefly)
- Nesting and variables are the marquee features, but Sass also supports patterns for eliminating duplication through file partials/imports and inheritance, much like a general-purpose programming language.
- Sass supports operators (numeric and logical), which can, for example, drive a debug block based on a debug flag variable declared at the top of the file.
Version control practice with Sass
Once a project adopts SCSS, only the .scss source files should be checked into version control. The compiled .css output — like any build artifact — shouldn’t be, because it will inevitably drift out of sync with the source and creates unnecessary merge conflicts and repository bloat. The same applies doubly to source map files, which go stale even faster than the CSS output as files change.
Module 3: Three Ways to Center Elements
Centering content is arguably the most commonly searched HTML/CSS question on the internet, and there are dozens of ways to achieve it. Three primary methods are covered here, ranked from most robust/most complex to simplest/most limited.
Method 1: Flexbox (display: flex)
Flexbox is applied to a container, not to the individual items you want centered — putting display: flex on the items themselves does nothing useful.
#main {
display: flex;
}
Flex is not a single property but an entire layout system with its own defaults, so additional properties are usually required to get the desired effect:
#main {
display: flex;
align-items: center; /* centers in the row/cross axis */
flex-direction: column; /* switches to a vertical stack */
}
Without flex-direction: column, flex defaults to a row orientation, which centers items but not in the “stack of block elements” mental model most developers expect. Adding flex-direction: column combined with align-items: center produces horizontally centered elements laid out vertically — the layout most people picture when they say “centered.”
Method 2: margin: 0 auto
This method centers a single element (often a container) horizontally within its parent:
h1 {
margin-left: auto;
margin-right: auto;
width: 400px;
}
The mechanism: the browser is told to figure out whatever the left and right margins should be. Since there’s no reason one side should be bigger than the other, the only solution that satisfies “auto” on both sides equally is to make them equal — which puts the element in the horizontal center.
Two preconditions are required for this to work:
- The element must have an explicit width. An element with an indeterminate/default width (like an
<h1>with no width set) won’t visibly center until it has a fixed size. - The element must be
display: block. Images, for example, default todisplay: inline, so centering an image this way requires also setting:
img {
display: block;
width: 1200px;
margin-left: auto;
margin-right: auto;
}
Applying this to a full-width container div is a bit of a mind-bender at first: a <div> is block-level by default and, unless otherwise constrained, already occupies the entire horizontal space — meaning its midpoint is already at the center. That’s technically “centered” but useless on its own. The useful case is combining a explicit, reduced width with the auto margins:
#main {
width: 70%;
margin-left: auto;
margin-right: auto;
}
This gives a container that shrinks to a set percentage of the available width and sits centered, with room to breathe on either side — while content inside it (like a left-aligned heading) can still be positioned independently within that centered box.
Shorthand: margin: 0 auto is equivalent to the two longhand declarations above. The margin property follows a clockwise box-model order starting at the top; specifying only two values applies the first to top/bottom and the second to left/right. So margin: 0 auto means “0 top and bottom, auto left and right.”
Method 3: text-align: center
The simplest, and least flexible, method:
p {
text-align: center;
}
Despite the name implying text, text-align: center centers all inline content within the element, including images. The bigger caveat: applying text-align: center to a container centers not just that container’s direct content but also everything nested beneath it — it acts like a hammer where a paintbrush is what’s really needed. This is the correct tool only for single elements with no meaningful children; anywhere finer control is needed, prefer flexbox or margin: 0 auto.
Comparing the three methods
| Method | Applies to | Requires fixed width? | Requires display: block? | Scope of effect | Best for |
|---|---|---|---|---|---|
display: flex + align-items/flex-direction | Container | No | N/A (flex items) | Whole flex layout system | Robust, modern layout; the long-term right answer |
margin: 0 auto | The element itself | Yes | Yes | Just that element | Quick centering of a container or fixed-size element |
text-align: center | Container (cascades to descendants) | No | No | Entire subtree | Single elements with no children needing independent control |
flowchart TD
Start{What are you centering?}
Start -->|A whole layout section,<br/>want full control| Flex[Use display: flex<br/>+ align-items + flex-direction: column]
Start -->|A single block element<br/>with a known width| Margin[Use margin: 0 auto<br/>+ explicit width + display: block]
Start -->|A simple leaf element,<br/>no meaningful children| TextAlign[Use text-align: center]
Flex --> Done[Centered content]
Margin --> Done
TextAlign --> Done
Flexbox is the most comprehensive and future-proof of the three — the mental model is that of a layout system designed from the ground up to solve alignment problems, rather than repurposing properties (margin, text-align) that were designed for other purposes and happen to produce a centered result as a side effect.
Module 4: Building a Collapsible List Without Script
Why collapsible lists matter
Cognitive load — the amount of information and decision-making a user has to process at once — is a central UX concern. Presenting too many choices at once causes users to miss details and have a poor experience. Collapsible lists manage this by letting a menu item represent a broad category, then reveal detail only when the user asks for it.
Two script-free techniques are covered, plus a note on when script becomes worthwhile.
Technique 1: The checkbox + label hack
Background: useful CSS combinator selectors
- Adjacent sibling combinator (
+) selects an element that is an immediate peer of another:
div + span { /* the span immediately following the div */ }
- General/subsequent sibling combinator (
~) selects a sibling anywhere after another, even with other elements in between:
label ~ span { /* any span that is a later sibling of the label */ }
Building the state machine
Since scripting is off the table, the collapsed/expanded Boolean state needs an HTML-native representation. A checkbox is exactly that: an on/off value. CSS provides the :checked pseudo-class to track and react to that state — this is the first piece of cleverness.
<input type="checkbox" id="toggle1">
<ul>
<li>Item one</li>
<li>Item two</li>
</ul>
input#toggle1 + ul { display: none; }
input#toggle1:checked + ul { display: block; }
This technically works, but showing a bare checkbox to toggle a menu isn’t a UI metaphor users expect — they expect something like a plus/minus expander icon.
Adding a visible expand/collapse indicator
Two <span> elements represent the plus and minus glyphs:
<input type="checkbox" id="toggle1">
<span class="plus">+</span>
<span class="minus">-</span>
<ul>
<li>Item one</li>
</ul>
input#toggle1 ~ .plus { display: inline-block; }
input#toggle1:checked ~ .plus { display: none; }
input#toggle1 ~ .minus { display: none; }
input#toggle1:checked ~ .minus { display: inline-block; }
input#toggle1 + ul { display: none; }
input#toggle1:checked + ul { display: block; }
Because the <ul> is now separated from the checkbox by the two spans, the sibling combinator (~) is required instead of the adjacent combinator (+) for it as well.
Cleverness #2: driving the checkbox with a <label>
The checkbox itself is still visible and still doesn’t match the expected click target. In HTML, a <label> can drive focus to — and, for a checkbox, toggle the value of — a target field. Wrapping the plus/minus indicator in a <label for="toggle1"> lets users click the visible plus/minus glyph to change the (now hidden) checkbox state:
<div>
<input type="checkbox" id="toggle1" class="hidden-checkbox">
<label for="toggle1">
<span class="plus">+</span>
<span class="minus">-</span>
</label>
<ul>
<li>Item one</li>
</ul>
</div>
.hidden-checkbox { display: none; }
The result: a collapsible list driven entirely by a hidden checkbox and a label, with no script. Multiple independent lists can be created by copying this block and giving each one a unique checkbox id and matching label for.
sequenceDiagram
participant User
participant Label
participant Checkbox as Hidden Checkbox
participant CSS as CSS (:checked selectors)
participant List as ul (collapsible content)
User->>Label: Clicks plus/minus label
Label->>Checkbox: Toggles checked state
Checkbox->>CSS: :checked pseudo-class changes
CSS->>List: display: none <-> display: block
CSS->>Label: plus/minus spans swap visibility
Technique 2: <details> / <summary>
HTML5 provides a purpose-built element pair for exactly this pattern:
<details>
<summary>Click to expand</summary>
<ul>
<li>Item one</li>
<li>Item two</li>
</ul>
</details>
The <summary> element is the always-visible expander/label; everything else inside <details> is the collapsible content. This is fully native, requires no CSS trickery, and needs no script at all.
Which to use
The checkbox/label technique is a fun and instructive exercise, and it’s genuinely useful in contexts where you don’t have full control over the HTML being generated and can’t add script (e.g., some HTML-driven CMS systems). The <details>/<summary> pair is the more formal, standards-based answer and should generally be preferred when available. That said, collapsible-menu scripts have existed since nearly the dawn of the web and are part of virtually every real UI framework — for anything beyond quick, simple cases, a small amount of script driving the list is usually the better long-term choice.
Module 5: Making HTML Accessible to Everyone
A mental model for accessibility investment
A useful analogy: under accessibility laws like the U.S. Americans with Disabilities Act (1990), public accommodations must provide reasonable accommodations. A restaurant needs a reasonable number of accessible parking spaces; a physical therapist’s office — whose clientele skews more heavily toward people with physical challenges — reasonably needs proportionally more. If both businesses are located in a retirement community with a very high median age, an even larger share of any nearby business’s audience will have accessibility needs.
The takeaway: how much explicit accessibility work is warranted depends on your actual and expected audience. But there is good news — many accessibility properties can be baked into how you already work, rather than treated as a separate cleanup phase at the end of a project. Building certain habits means accessibility becomes automatic rather than a bolt-on afterthought.
Problem 1: text baked into an image
A heading rendered as an image of text (rather than real HTML text) has several practical drawbacks (bandwidth, mobile responsiveness, localization) — but for a user with visual impairments relying on a screen reader, that text may not exist at all. A screen reader can’t read pixels; the “text” is invisible to assistive technology unless the image happens to be run through OCR by high-end assistive tools — not something to depend on.
Preferred fix: use real HTML text with real fonts instead of a baked image.
Fallback fix, when the image can’t be replaced: provide an alt attribute with the equivalent text:
<img src="header.png" alt="Retro Arcade & Guitar Repair">
This makes the content available to assistive technology even though the visual presentation stays the same.
Problem 2: communicating meaning with color alone
Consider two buttons — one destructive (“delete all account data”), one safe — distinguished only by red vs. green coloring. For a portion of the population (color blindness affects roughly 8–10% of people, depending on ethnicity, most commonly affecting red/green discrimination — precisely the colors Western culture conventionally uses for danger/safety), the two buttons can be visually indistinguishable, especially at a glance or in a dense UI.
Fix: never rely on color as the sole signal. Add a secondary cue — an icon, a symbol, explicit label text — so the meaning survives even if color can’t be perceived:
<button class="danger">⚠ Delete Account</button>
<button class="safe">✓ Save Changes</button>
This doesn’t mean color shouldn’t be used for communication — only that it shouldn’t be the only channel carrying the message.
Problem 3: fixed font sizes
A page with a small, fixed font size (e.g., font-size: 7pt) becomes unreadable for a large share of users — and given that visual acuity naturally declines with age, the affected share of any general audience trends toward the majority over time. Simply doubling the fixed size (e.g., to 14pt) only solves the problem for users whose ideal reading size happens to match that specific value.
Fix: use font sizes that are relative to the user’s own browser/OS default, rather than absolute. The recommended unit is rem (root em, relative to the root element’s font size) rather than fixed units like points or pixels.
body {
font-size: 1rem; /* respects the user's own default size setting */
}
A handy rule of thumb for converting an existing point-based mental model to rem: 1pt ≈ 8.36% of the base rem value (i.e., roughly pt-value × 0.0836 in rem), assuming the user’s default text size. The key point isn’t the exact conversion factor — it’s that relative units let the browser honor whatever default size the user has actually configured, rather than forcing everyone to the same absolute size.
The unifying idea: contrast
Every one of these techniques — avoiding text-in-images, not relying on color alone, and not fixing font size — serves the same underlying goal: contrast. Whether it’s contrast stark enough for a machine to reliably extract and read text aloud, or visual contrast sufficient for a low-vision user to distinguish foreground text from its background, contrast is the thing that actually matters. A readable font size undermined by poor color contrast (e.g., light gray text on a white background) is just as unreadable as text that’s simply too small. Making deliberate, sufficient-contrast design choices is what actually makes content legible for the whole audience.
mindmap
root((Accessible HTML))
Text vs images
Prefer real text
alt text as fallback
Color
Never color alone
Add icon/label/pattern
Font size
Use rem, not fixed pt/px
Respect user's browser default
Contrast
The common thread
Applies to color AND size
Module 6: Five Ways to Make Text More Readable
Five concrete techniques for taking hard-to-read text and making it legible, ordered from the most obvious to the more subtle.
1. Background color / contrast
The most fundamental fix: ensure sufficient contrast between text color and background color.
body {
background-color: #0e70f7;
color: white;
}
2. Delineating a primary content region
Rather than (or in addition to) changing the whole page background, give the actual content area its own background that visually separates it from surrounding page chrome (menus, sidebars):
#main {
background-color: #0e70f7;
}
This is a variation on the first technique, but it specifically helps distinguish primary content from secondary page furniture.
3. Padding — avoiding “halation”
Halation (a term borrowed from optics) is the halo effect that appears around bright content when there’s strong contrast with no breathing room. In text, letters placed right up against a high-contrast background edge tend to visually blend into that halo, hurting legibility.
#main {
padding: 20px;
}
Adding padding creates space between the content and the edge of its container, resolving the halation effect. The same idea (via margin) applies to spacing between separate elements.
4. Letter spacing and line height
Two related properties control the density of text itself:
p {
letter-spacing: 0.2em; /* space between individual letters */
line-height: 1.3em; /* space between lines */
}
Both properties have a “too little” failure mode (letters/lines crowd together into an unreadable blur) and a “too much” failure mode (text becomes disconnected and hard to track). A letter-spacing around 0.2em and a line-height around 1.3em are reasonable, readable middle grounds — note that neither of these properties changes the font size itself, even though the overall text block appears larger.
5. Font family choice
Some fonts are simply harder to read at typical body-copy sizes than others.
| Font category | Example | Readability at body-copy size |
|---|---|---|
| Serif (ornate/script) | A heavy script/brush font | Poor — avoid for body text |
| Serif (classic) | Georgia | Better than Times New Roman, but still serif |
| Sans-serif | Gill Sans, and other clean sans-serif fonts | Best — general rule: serif fonts, bad; sans-serif fonts, good for on-screen body text |
body {
font-family: "Gill Sans", sans-serif;
}
Bonus technique: kerning
Kerning is the fine adjustment of spacing between specific pairs of letters (as opposed to uniform letter-spacing, which applies the same gap everywhere). Well-kerned text nudges certain letter combinations (like a period following a letter with an overhanging arm) closer together than a naive fixed-width gap would allow, which subtly improves readability, particularly in proportional (non-monospace) fonts.
p {
font-kerning: normal; /* default: browser applies font's built-in kerning pairs */
}
Disabling kerning (font-kerning: none) pushes every character apart by a uniform, monospace-like distance regardless of the specific letter shapes involved — a subtle but real readability difference.
Quick-reference table
| Technique | CSS property/properties | Problem it solves |
|---|---|---|
| Background contrast | background-color, color | Text doesn’t stand out from its background |
| Content area delineation | background-color on a wrapping container | Primary content isn’t visually separated from surrounding chrome |
| Padding | padding | Halation / text blending into a high-contrast edge |
| Letter spacing & line height | letter-spacing, line-height | Text too cramped or too sparse |
| Font family | font-family | Ornate/script fonts are inherently hard to read |
| Kerning | font-kerning | Uneven visual spacing between specific letter pairs |
Module 7: Working with Custom Fonts
The default: browser fallback fonts
If no font-family is specified, the browser falls back to its own default (commonly Times New Roman on many systems/browsers) — a perfectly serviceable font, but one you have no control over unless you specify something.
Because you never control which browser or OS your visitors use, and you have no direct control over which fonts are actually installed on their machines, font choices need to be conservative unless a webfont is explicitly loaded.
The most conservative approach: generic font families
Rather than naming a specific font, you can name a generic family and let the browser pick its own best match:
body {
font-family: sans-serif;
}
The browser resolves the generic family to a concrete installed font — for example, Arial for sans-serif on many systems, Comic Sans MS for cursive, or Impact for fantasy. You can also name a more specific font as a preference, with the generic family as an automatic fallback:
body {
font-family: "Gill Sans MT", "Verdana", sans-serif;
}
Verdana is common on Microsoft systems; Gill Sans MT is common but not universally web-safe — hence still including a generic fallback at the end of the stack.
Taking finer control: web fonts
Services such as Google Fonts host fonts made available under permissive open licenses. The workflow:
- Pick a font from the font library, view its available styles/weights.
- Copy either the
<link>embed snippet or the CSS@importsnippet the service provides — both achieve the same result; there’s no material difference between them. - Reference the font by name in
font-family, with a sensible generic fallback in case loading fails:
<link href="https://fonts.googleapis.com/css2?family=Rubik+Microbe&display=swap" rel="stylesheet">
body {
font-family: "Rubik Microbe", cursive; /* cursive is the fallback if the webfont fails to load */
}
Self-hosting fonts
If you don’t want to depend on a third-party font CDN (for example, on an intranet, or behind a firewall that blocks cross-domain font requests), the font files can be downloaded directly from the font service and self-hosted, adjusting the @font-face/link paths accordingly. A public font CDN is generally reliable, but self-hosting removes the external dependency entirely.
Quick-reference: font strategy comparison
| Strategy | Control over exact appearance | External dependency | Best when |
|---|---|---|---|
Generic family only (e.g., sans-serif) | Low (browser picks) | None | Maximum compatibility, minimal design requirements |
| Specific system font with generic fallback | Medium | None | Targeting a known OS/platform, with graceful fallback |
| Web font via CDN (e.g., Google Fonts) | High | Yes (CDN) | Full design control, public-facing site |
| Self-hosted web font | High | None | Intranet, firewalled environment, or CDN dependency undesirable |
Module 8: Filtering User Input with Simple HTML
HTML has quietly accumulated a large number of specialized input types and attributes over the years — enough that many working developers aren’t aware of everything available. This module focuses specifically on limiting and validating user input using nothing but HTML attributes.
Input masking types
These input types actively prevent invalid characters from being entered at all — the browser enforces the format as the user types.
<input type="date">
<input type="week">
<input type="time">
<input type="color">
type="date"provides true input masking plus a full calendar picker UI. Invalid values (e.g., a month of 14) are simply rejected/not accepted. Note that the displayed date format depends on the user’s OS/browser locale settings, not a fixed format you control from markup alone.type="week"accepts a week number and a year. Because a year can have a 53rd partial week (even a leap year contributes only 1–2 extra days), valid values run slightly beyond the naive assumption of 52; the browser correctly rejects out-of-range values like 59 or 99. The calendar picker UI, interestingly, still only visually displays up to week 52, folding any remaining day into that last week.type="time"accepts hours, minutes, and seconds, with a picker widget. Note that the displayed format (12-hour vs. 24-hour) is again governed by the user’s own system settings.type="color"provides a full color-picker UI (familiar to anyone who’s used Photoshop or GIMP), letting the user cycle between RGB, HSL, and hex representations.
Pattern-validated input types
The next group of types provides looser masking (or none) but supports format validation via the pattern attribute (a regular expression) or built-in structural checks, surfaced when the form is submitted.
<input type="tel" pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}">
A tel input does not mask input as you type, but a pattern regular expression — here, three digits, a dash, three digits, a dash, four digits — is checked on submit, and the browser shows a validation message if the pattern doesn’t match.
<input type="email" pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}">
An email input alone only checks for the bare minimum (an @ sign); adding an explicit pattern tightens this to a fuller, more correct address format.
<input type="url">
Numeric and range inputs
<input type="number" min="0" max="100" step="10">
type="number" rejects non-numeric characters outright and enforces min/max bounds on submit; the step attribute controls the increment applied by the up/down spinner arrows (and by keyboard).
<input type="range" min="0" max="100" step="10">
type="range" is a different UI (a slider) for essentially the same underlying value-with-bounds problem that number solves. It’s best suited to situations where the precise value matters less than getting into the right general neighborhood — e.g., a volume control, where landing within an order of magnitude of “loud enough” matters more than an exact numeric value.
Character length limiting
<input type="text" maxlength="3">
maxlength is a very old, broadly supported attribute that simply stops accepting further characters once the limit is reached — regardless of how furiously the user keeps typing.
Mobile-specific behavior
On mobile platforms, several of these input types get an even bigger practical benefit: a matching on-screen keyboard. A number or tel input, for example, typically triggers a numeric-only keypad on Android — much more convenient than a full keyboard on a small screen. Mobile platforms have some latitude in how they implement these input experiences, so it’s worth checking actual behavior on the relevant target devices.
Input type reference table
| Input type / attribute | Masking behavior | Validated on submit | Notable UI |
|---|---|---|---|
date | Full masking | Yes | Calendar picker |
week | Full masking (up to week 53) | Yes | Calendar picker (caps display at week 52) |
time | Full masking | Yes | Clock picker |
color | N/A (picker only) | N/A | RGB/HSL/hex color picker |
tel + pattern | None | Yes (regex) | Plain text field |
email + pattern | Minimal (@ check) built-in; full with pattern | Yes | Plain text field |
url | None | Yes (built-in URL structure check) | Plain text field |
number + min/max/step | Numeric characters only | Yes (bounds) | Spinner arrows |
range + min/max/step | N/A (slider only) | N/A (always in range) | Slider |
maxlength attribute | Truncates further input | N/A | Any text-like input |
Browser support caveat
All of these are valid HTML5 input types, but support and exact behavior can vary across browsers (particularly Firefox vs. Chromium engines). Always verify actual browser support before depending on a given input type in production — build a quick proof of concept and check it in your actual target browsers rather than assuming universal support. (Module 10 covers a systematic way to check this.)
Module 9: Working Effectively with Semantic HTML
The problem with meaningless names
Consider the historical evolution of variable naming: early terse names like func25, improved (but still cryptic) Hungarian-notation names like sFirstName, and finally clear, descriptive names. The value of a good name isn’t just readability — it’s that a good naming convention makes wrong code look wrong. (This precise framing is drawn from a well-known piece by Joel Spolsky on the same theme.) If a variable is misleadingly named — say, a variable clearly meant for “first name” is actually holding a last name — that mismatch is immediately visible because the name carried real meaning.
The same principle applies to HTML structure. Consider typical “old-style” markup:
<div id="header">...</div>
<div id="menu">
<ul class="user-list">...</ul>
</div>
<div id="main">
<div class="section">...</div>
</div>
This is not wrong, but every one of these id/class names is an ad hoc convention that has to be independently invented, remembered, and typed correctly every single time — and there’s nothing to stop a developer from misusing an id where a class belongs, or vice versa, or from inventing a slightly different name for the same recurring structural idea in a different file. If a div’s semantic role were suddenly stripped away, all that’s left is <div> — a purely generic container with no intrinsic meaning at all. A div is a div is a div.
What “semantic” means here
Semantic HTML aims to eliminate this entire class of naming problems by giving structural elements intrinsic meaning baked into the tag itself, rather than relying on ad hoc id/class conventions layered on top of meaningless <div>s.
The same idea also applies at the inline level. Old-style markup used purely presentational tags:
<b>Warning</b>
<i>Note</i>
<b> and <i> describe typography (bold weight, italic slant) — not meaning. A screen reader encountering <b> has no way to know why something is bold; it might be bold for emphasis, or for an entirely different typographic reason. The semantic replacements describe intent directly:
<strong>Warning</strong>
<em>Note</em>
<strong> communicates importance/strength; <em> communicates emphasis. In most browsers, these visually render the same way as <b>/<i> by default (bold and italic respectively) — but a screen reader, search engine, or any other automated consumer now knows the actual intended meaning, not just an arbitrary typographic side effect.
Semantic elements are (mostly) meaning, not new functionality
Semantic elements generally don’t add new browser functionality by themselves — much like <strong> replacing <b>, the visual result is often unchanged. Their value lies in comprehensibility for other consumers of the markup: developers reading the code later, screen readers and other assistive technologies, and search engines / internal indexers building an understanding of page structure. Wrapping primary content in <article> tags, for instance, makes that content reliably extractable by tools that understand the semantic model — something that’s only inconsistently achievable with an arbitrarily named <div class="article">.
Converting a page section by section
Starting from typical id/class-driven divs, each structural region can be translated to its semantic equivalent:
<!-- Before -->
<div id="header">...</div>
<!-- After -->
<header>...</header>
<!-- Before -->
<div id="menu">
<ul>...</ul>
</div>
The id="menu" name is itself semantically ambiguous — is this a navigation menu for the site, or an actual food/product menu? The <nav> element removes the ambiguity entirely:
<!-- After -->
<nav>
<ul>...</ul>
</nav>
<!-- Before -->
<div id="main">...</div>
<!-- After -->
<main>...</main>
For a block of related paragraphs, there’s a real judgment call between <section> (groups related content) and <article> (a self-contained piece of content in its own right). Multiple paragraphs that are all related to each other as a cohesive unit are often better modeled as an <article> than a generic <section> — either is legitimate; either is a strict improvement over a meaningless <div>.
<!-- Before -->
<div class="section">
<p>...</p>
<p>...</p>
</div>
<!-- After -->
<article>
<p>...</p>
<p>...</p>
</article>
A closing callout/pull-quote div similarly maps to the <aside> element:
<!-- Before -->
<div class="parenthetical">...</div>
<!-- After -->
<aside>...</aside>
The <time> element: encoding meaning that survives time itself
A more functionally significant semantic element is <time>, used with a machine-readable datetime attribute:
<article>
<h2>What I'm Doing Today</h2>
<p>
<time datetime="2023-12-24">Today</time>, I'm spending the day with my family.
</p>
</article>
The word “today” is inherently ambiguous across time — read six months later, that sentence loses its context entirely. By encoding the actual date in the datetime attribute (following the ISO 8601 standard), the meaning stays intact regardless of when the page is read, and any tool scanning the page (a search engine, an archival crawler) can reliably extract “this content refers to December 24, 2023” without any natural-language date parsing.
Header/footer pairing
If a page uses a <header> element, convention (and, in some interpretations, the spec) expects a matching <footer>:
<footer>
<p>Created: <time datetime="2023-01-15">January 15, 2023</time></p>
</footer>
Semantic HTML5 elements at a glance
| Element | Replaces / disambiguates | Typical use |
|---|---|---|
<header> | <div id="header"> | Introductory content for a page or section |
<nav> | <div id="menu"> | Navigation links, unambiguously distinct from a literal menu |
<main> | <div id="main"> | The primary unique content of the page |
<section> | <div class="section"> | A thematic grouping of content |
<article> | <div class="section"> | A self-contained, independently distributable piece of content |
<aside> | <div class="parenthetical">/callout | Tangentially related content, pull-quotes, callouts |
<footer> | End-of-page/section <div> | Closing metadata, copyright, secondary links |
<time> | Plain text date strings | Machine-readable dates/times via datetime |
<strong> | <b> | Importance/strength (not just bold typography) |
<em> | <i> | Emphasis (not just italic typography) |
flowchart TD
D["Generic <div> with id/class"] --> Q{What role does<br/>this region play?}
Q -->|Page/section intro| Header["<header>"]
Q -->|Site/page navigation| Nav["<nav>"]
Q -->|Primary unique content| Main["<main>"]
Q -->|Thematic grouping| Section["<section>"]
Q -->|Self-contained content unit| Article["<article>"]
Q -->|Tangential / callout content| Aside["<aside>"]
Q -->|Closing metadata| Footer["<footer>"]
Why bother? It’s not even more code
The translation from meaningless divs to semantic elements typically costs no additional effort — often it’s fewer keystrokes than the equivalent id/class-laden div. The payoff is markup that’s simultaneously more comprehensible to the next developer, more useful to assistive technology, and more extractable by any tool that understands the semantic vocabulary.
Module 10: Knowing What Works in Which Browsers
Why this matters less than it used to — but still matters
Cross-browser compatibility used to be a much bigger daily struggle, to the point where entirely different pages were sometimes served based on user-agent sniffing just to get something usable in front of users of different browsers. The rise of Chromium-based browsers has largely (though not entirely) unified the landscape.
As of the time of recording, market share was roughly:
| Browser engine / browser | Approximate market share |
|---|---|
| Chrome + other Chromium browsers | ~66% (two-thirds) |
| Safari | ~19% |
| All others combined | Small remainder |
(For comparison, mobile OS share skews similarly non-intuitively: Android holds roughly 4/5 of the mobile device market, even though iPhone may be the single most common individual device model.)
Step zero: know your actual audience
Before worrying about compatibility in the abstract, the real question is: what browsers does your audience actually use? General market-share numbers are a reasonable proxy for public-facing sites, but they can be wildly wrong for other contexts — for example, intranet applications governed by internal IT policy may still mandate legacy browsers for compatibility reasons, even years after those browsers are otherwise obsolete.
A practical, from-first-principles way to find out for an existing application:
- Pull a single day’s web server log (the exact day doesn’t matter much).
- Import the log into a spreadsheet.
- Identify the
User-Agentcolumn. - Get the distinct set of user-agent strings and count occurrences of each.
- For unfamiliar user-agent strings, a quick web search almost always identifies the browser/version/platform they represent.
The result is a concrete breakdown of what your actual audience is using, which tells you how much browser-compatibility effort is actually warranted.
flowchart TD
A[Pull one day's web server log] --> B[Import into a spreadsheet]
B --> C[Isolate the User-Agent column]
C --> D[Get distinct values + counts]
D --> E{Recognize the<br/>user agent string?}
E -->|No| F[Web search the string]
E -->|Yes| G[Tally into browser/version buckets]
F --> G
G --> H[Concrete picture of your<br/>actual audience's browsers]
As a rule of thumb: designing for Chrome (and by extension, other Chromium browsers) covers the majority case. Safari matters if there’s a meaningful Mac-using audience preferring it over Chrome. Edge, being Chromium-based, is very likely to behave the same as Chrome, though it’s still worth testing anything you’re using “out on the perimeter” of the HTML/CSS spec. Chrome + Edge + Safari together covers roughly 88% of a typical general audience; reaching 90%+ generally requires also accounting for Firefox, which — while not as dominant — still tracks the HTML5 spec closely (a compliance score in the low-to-mid 90s%, compared to Chrome’s mid-90s%).
The gold-standard method: build a proof of concept
Nothing beats actually standing up a small proof-of-concept and testing it in the real target browser. That said, this is a lot of overhead to invest before you’ve even chosen a technology — so it’s useful to have lighter-weight ways to check support before committing.
Two reference tools
HTML5test.com — detects your current browser’s user agent and reports a numeric score (out of ~555 possible points) reflecting known HTML5 feature support, along with a detailed feature-by-feature breakdown. For example, testing revealed that Firefox lacks support for the week and month input types (covered in Module 8), while Chromium-based browsers support both — directly explaining why a type="month" input degrades to a plain text field with no masking or picker UI in Firefox.
CanIUse.com — a broader compatibility reference not limited to HTML5, covering CSS features as well. Searching a feature (e.g., “CSS cross-fade”) returns a browser/version support matrix, using both color and pattern-coding so support gaps are visible at a glance. A “Compare Browsers” view provides a comprehensive matrix across all tracked features and versions.
| Site | Scope | Output |
|---|---|---|
| HTML5test.com | HTML5 feature support | Single overall score + detailed feature list, based on your current browser’s user agent |
| CanIUse.com | Broad web platform features (HTML, CSS, JS APIs) | Per-feature browser/version support matrix |
Practical workflow for a compatibility question
flowchart TD
A[Have a feature/technique in mind] --> B{Need broad HTML5<br/>support overview?}
B -->|Yes| C[Check HTML5test.com]
B -->|No, more specific feature| D[Search the feature on CanIUse.com]
C --> E{Feature supported<br/>in target browsers?}
D --> E
E -->|Yes| F[Build with confidence]
E -->|Uncertain / no| G[Build a small proof of concept<br/>and test in the real target browser]
If you specifically need to check Safari and don’t have access to macOS, options include a Mac VM, a remote test machine, or an online cross-browser testing service — all reasonable but higher-overhead than a quick compatibility lookup.
Module 11: Using a UI Framework Like Bootstrap
Why use a framework at all
HTML and CSS are broad, general-purpose tools for an enormous class of problems. Within that broad class sits a much narrower set of recurring patterns — especially around forms and common layout needs — that get solved over and over. These patterns can always be coded from scratch each time, but a UI framework packages proven solutions to these recurring problems so you don’t have to keep reinventing them.
Important note: this is the one place in the “no script” course where an exception is made — Bootstrap itself uses JavaScript internally for some of its interactive components. The commitment here is only that you, the developer following along, never have to write script yourself; the framework’s own internal script is treated as an accepted dependency.
Getting Bootstrap into a page
Bootstrap can be installed via npm, but the lowest-friction path for experimentation is the Bootstrap CDN:
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/js/bootstrap.bundle.min.js"></script>
What changes immediately, even with no other markup changes
Simply including Bootstrap’s CSS — with no other markup changes at all — visibly changes the page. Inspecting the computed styles reveals Bootstrap applies a broad baseline reset and default styling to elements that previously relied on whatever the browser’s own default (user-agent) stylesheet happened to specify. The practical benefit: pages look consistent across different browsers, because they’re no longer at the mercy of each browser’s own, potentially differing, default styling.
One especially visible change is the default font. Bootstrap sets the body font-family to a CSS custom property resolving to system-ui — not a literal named font, but an instruction to use whatever the operating system’s own default UI font is (e.g., the Windows system UI font). The practical effect: text on the page matches whatever font the user already associates with their own OS’s native applications, rather than an arbitrary browser default or an explicitly named webfont.
Styling tables the Bootstrap way
Starting from a plain, unstyled <table> (used here for genuinely tabular data, its intended purpose — Bootstrap also has a separate, dedicated grid system for page layout, not covered here):
<table class="table">
...
</table>
Adding the table class alone makes the table fill the available horizontal width responsively. Additional utility classes layer on progressively:
<table class="table table-striped table-hover table-bordered table-sm">
<thead>
...
</thead>
<tbody class="table-group-divider">
...
</tbody>
</table>
| Class | Effect |
|---|---|
table | Base responsive table styling |
table-striped | Alternating row background color, automatically color-sensitive to the surrounding theme |
table-hover | Highlights the row under the mouse cursor — useful for visually tracking a row across dense tabular data |
table-bordered | Adds visible cell borders |
table-sm | Reduces cell padding for a more compact table |
table-group-divider (on <tbody>) | Adds a stronger visual divider between header and body |
Notably, table-striped applies as a single class on the <table> itself, rather than requiring alternating classes to be manually applied to every other row — a common pattern people otherwise reimplement by hand.
Styling forms the Bootstrap way
<form>
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<div class="input-group">
<span class="input-group-text" id="basic-addon1">@</span>
<input
type="text"
class="form-control"
id="username"
placeholder="Username"
aria-describedby="basic-addon1"
aria-label="Username">
</div>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
Notes on the classes involved:
mb-3— a spacing utility class for “margin-bottom, size 3” on Bootstrap’s spacing scale.input-group+input-group-text— groups a decorative/label element (here, an ”@” symbol) together with its associated input, visually and semantically.form-control— Bootstrap’s baseline styling for form inputs.aria-describedbyandaria-label— accessibility attributes that help users of assistive technology understand what the field is for. The further you move away from bare, default HTML, the more deliberate effort is required to preserve accessibility — and Bootstrap’s own documented patterns generally do a good job of building this in by default, provided you follow them.
The value proposition, summarized
What a framework like Bootstrap provides is, in effect, a reset of inconsistent browser defaults, plus a strong, tested base of solutions for problems you’d otherwise end up reinventing piecemeal anyway. Learning even a modest amount of a UI framework substantially expands the number of “plays” available beyond what’s practical to hand-roll from scratch every time.
Module 12: Creating Compelling Animations Using Only CSS
CSS animation works by describing discrete states of an element and letting the browser interpolate the transition between them — all without any script.
The basic building blocks: @keyframes and animation
@keyframes pulse {
from {
color: black;
font-size: medium;
font-weight: normal;
}
to {
color: red;
font-size: large;
font-weight: bold;
}
}
#highlight {
animation-name: pulse;
animation-duration: 0.5s;
}
from/to describe a simple two-state animation. Attaching it to an element requires, at minimum, an animation-name and an animation-duration — without both, nothing will visibly happen even if the keyframes are correctly defined.
Making the animation cycle smoothly
By default, an animation snaps back to its starting state abruptly at the end of each cycle. A smoother, “breathing” effect uses animation-direction: alternate, which runs the animation forward once and then backward once:
#highlight {
animation-name: pulse;
animation-duration: 0.5s;
animation-direction: alternate;
animation-iteration-count: 2; /* alternate needs a minimum of 2 to complete one forward+back cycle */
}
Because alternate effectively runs the animation twice (once each direction), a minimum animation-iteration-count of 2 is needed to see one complete forward-and-back cycle. Looping forever is as simple as:
#highlight {
animation-iteration-count: infinite;
}
Reusing one animation across multiple elements, with staggered timing
The same @keyframes block can be attached to multiple elements independently, each with its own timing:
#highlight-en {
animation-name: pulse;
animation-duration: 0.5s;
animation-direction: alternate;
animation-iteration-count: 2;
}
#highlight-de {
animation-name: pulse;
animation-duration: 0.5s;
animation-direction: alternate;
animation-iteration-count: 2;
animation-delay: 1s; /* starts right after the first element's animation completes */
}
Multi-stage animations with percentage keyframes
Once an animation needs more than two states, percentage-based keyframes replace from/to:
@keyframes pulse {
0% { color: black; }
50% { color: red; }
100% { color: green; }
}
Combined with animation-direction: alternate, this produces a black → red → green → red → black cycle. Crucially, the browser doesn’t just snap between the named color stops — it visibly interpolates through intermediate colors between keyframes (e.g., passing through a brownish tone between green and red), which is much easier to observe when animating position or size than when animating discrete named colors.
Animating position, size, and timing curves
@keyframes pulse-background {
0% { left: 0px; }
50% { left: 300px; }
100% { left: 600px; }
}
.square {
position: relative;
animation-name: pulse-background;
animation-duration: 4s;
animation-timing-function: ease; /* default: slight acceleration/deceleration at each end */
}
The animation-timing-function controls the interpolation curve between keyframes:
| Value | Effect |
|---|---|
ease (default) | Slight acceleration at the start, slight deceleration at the end |
linear | Constant speed, no acceleration or deceleration |
ease-in | Slow acceleration, no deceleration |
Animating width/height instead of (or alongside) left animates the element’s size instead of (or together with) its position — the same mechanism works across most animatable CSS properties, including combining several at once (position, size, rotation, font-size, and so on, simultaneously).
Shorthand
The individual animation-* properties can be collapsed into a single animation shorthand property specifying name, duration, direction, and iteration count together, producing an identical result with less code.
A tasteful, practical example: a subtle “wiggle”
@keyframes wiggle {
0%, 7%, 40%, 100% { transform: rotateZ(0deg); }
10% { transform: rotateZ(-15deg); }
30% { transform: rotateZ(10deg); }
}
.icon {
transform-origin: center; /* the pivot point for rotateZ */
animation: wiggle 1.5s ease infinite;
}
Listing more than one percentage value on the same keyframe line (e.g., 0%, 7%, 40%, 100%) means those keyframes share the same declared values — here, all four “checkpoints” share a neutral, unrotated state, with two intermediate keyframes (10%, 30%) providing the actual left/right rotation that produces the wiggle. transform-origin defines the pivot point the rotateZ rotation operates around.
When to reach for script instead
Key-framing is unquestionably how CSS animation works, and there’s genuinely no limit to what can be built with enough primitives (boxes, SVG shapes, or images). But for anything beyond a small number of keyframes, hand-authoring the values becomes impractical — for example, the wiggle’s rotation values above are really the output of a simple decaying-oscillation formula (start at a seed magnitude, roughly negate and shrink it by two-thirds on alternating steps). That’s a handful of lines of script (or even math) versus many lines of hand-written CSS keyframes — and once scripted, parameters like amplitude and damping can be made dynamic/user-driven, which pure CSS keyframes cannot do.
Practical guidance: use pure CSS animation for a simple attention-grabbing effect or a light decorative motion touch. The moment you’re working toward genuine data visualization or more advanced, parameterized animation, favor a dedicated library (such as D3.js) or at least a small amount of driving script — CSS keyframes alone stop scaling gracefully well before that point.
flowchart TD
A[Need an animation?] --> B{How many keyframes /<br/>how much dynamic control?}
B -->|A couple of states,<br/>simple attention effect| C[Pure CSS @keyframes + animation]
B -->|Many states, or values need<br/>to be computed/dynamic| D[Use script<br/>or a library like D3.js]
Module 13: Reducing HTTP Requests with CSS Image Sprites
The core idea: economies of scale
The value proposition of CSS image sprites is reducing total request overhead by combining multiple small images into a single larger image, then using CSS to display only the relevant portion of it for each use.
Historically, this mattered enormously in bandwidth-constrained situations (e.g., a page with a hard, low total-size limit due to a satellite-link bottleneck) — but even without an extreme constraint, there are real, if now more modest, benefits:
- Fewer separate HTTP requests. Modern HTTP has gotten much more efficient over the years, so the pure bandwidth/header savings from combining requests are smaller than they used to be. What remains significant is that each separate request represents a full pass through the server’s request-processing stack (locating the resource, packaging it as a response, and applying transport overhead like TLS) — multiplied across many small images, this can add up to real cost and latency.
- Compression economies of scale. Combining many separately-compressed images (already compressed about as well as they individually can be) into one larger image often compresses to something smaller — sometimes much smaller — than the sum of the individually compressed originals, because the larger image gives the compressor more shared redundancy to exploit, and reduces the fixed per-file header overhead paid by every individual file.
Building a sprite sheet
Given three separate images (as an example, three same-sized images stacked vertically, each 1200×1200), the combined sprite sheet places them at known offsets — e.g., 0, 1200, and 2400 (assuming vertical stacking) — recorded as a simple map.
Converting <img> tags to background-image sprites
Starting point — three separate <img> requests:
<img id="monarch" src="monarch.jpg" alt="Monarch butterfly">
<img id="karner-blue" src="karner-blue.jpg" alt="Karner blue butterfly">
<img id="giant-owl" src="giant-owl.jpg" alt="Giant owl butterfly">
Converted to background-image-driven <div>s sharing a single sprite sheet:
<div class="butterfly" id="monarch"></div>
<div class="butterfly" id="karner-blue"></div>
<div class="butterfly" id="giant-owl"></div>
.butterfly {
height: 300px;
width: 300px;
margin: 0 auto;
background-image: url("butterflies-sprite.jpg");
/* Source sprite is 1200 wide x 3600 tall (1 x 3 of the individual images).
Displayed at 300px wide, so the background must be scaled to the same
aspect ratio: 300 wide x 900 tall (300 x 3). */
background-size: 300px 900px;
}
#giant-owl { background-position: 0px 0px; } /* top image in the sheet */
#monarch { background-position: 0px -300px; } /* middle image, shifted up by one image height */
#karner-blue { background-position: 0px -600px; } /* bottom image, shifted up by two image heights */
<img> tags don’t support background-image (there’s no meaningful “background” behind a replaced image element), which is why the sprite technique requires switching the markup from <img> to plain <div>s driven entirely by CSS background properties. The background-size must be scaled to preserve the sprite sheet’s original aspect ratio at the target display size, and background-position offsets by the appropriate negative pixel amount to reveal only the intended sub-image, masking everything else with the element’s fixed height/width acting as a viewport onto the larger sheet.
Measuring the actual payoff — and its limits
In one real comparison: three separately compressed JPEGs totaled 4.78 MB combined, while the single combined sprite totaled 4.75 MB — only a very minor savings in this particular case. At that scale, the time savings from fewer requests can also be a wash, since separate requests can be processed in parallel by the browser, and the extra time needed to download one larger combined file can offset the advantage of avoiding multiple round trips.
Practical guidance
- Always measure. Don’t assume sprites are a win — verify with real numbers (request waterfall timing, total payload size) that the technique is actually helping in your specific case.
- Sprites pay off best with many small, frequently reused images — icon sets are the classic case — because those are exactly the images with the highest proportional per-request overhead.
- CSS sprites can make a real difference, no difference, or even a net-negative difference, depending on whether the underlying economies-of-scale assumption actually holds for your specific set of images.
flowchart TD
A[Many small separate images] --> B{Frequently reused,<br/>small icon-like images?}
B -->|Yes| C[Combine into a sprite sheet<br/>+ background-position per use]
B -->|No, few large images| D[Sprite savings likely minimal<br/>or even negative — measure first]
C --> E[Verify: total payload size<br/>and request timing actually improved]
Module 14: Creating Powerful Graphics with SVG in HTML
Raster vs. vector graphics
A raster (bitmap) image defines a fixed color/luminosity value for every pixel in a grid. This gives pixel-perfect precision at a given resolution but leaves no room for interpolation between pixels — zooming in beyond the image’s native resolution reveals blocky degradation.
A vector image instead stores instructions for drawing shapes (lines, curves, circles, and other primitives) rather than a fixed pixel grid. This means the same image can be rendered cleanly at essentially any zoom level — this is what “scalable” in SVG (Scalable Vector Graphics) refers to. Fonts are the everyday example most users unconsciously expect to behave this way: text stays crisp at any zoom level, even though users don’t consciously think of that as a “vector graphics” property.
Vector graphics also matter for anything dynamically generated from data — a bar chart, for example, needs some kind of drawable primitive (a rectangle) representing each bar; if you’re generating visuals from changing data at all, you eventually need a primitive-based approach in one form or another. SVG provides a complete, ready-made set of such primitives.
flowchart LR
subgraph Raster["Raster / Bitmap"]
R1[Fixed pixel grid] --> R2[Sharp at native resolution]
R2 --> R3[Blocky when zoomed in]
end
subgraph Vector["Vector (SVG)"]
V1[Drawing instructions:<br/>lines, curves, circles] --> V2[Re-rendered at any zoom]
V2 --> V3[Crisp at every scale]
end
Basic SVG primitives
An SVG document is itself a first-class embeddable HTML element:
<svg viewBox="0 0 800 800" width="800" height="800">
<!-- primitives go here -->
</svg>
viewBox defines the internal, dimensionless coordinate system the primitives inside are drawn against — the numbers have no inherent unit; they’re only meaningful relative to each other and to the viewBox’s own declared width/height. Omitting viewBox causes SVG content to be positioned against the element’s own rendered pixel dimensions by default, which is rarely what you actually want when composing a design.
Circle:
<circle cx="100" cy="100" r="100" fill="rgb(200, 220, 255)" />
cx/cy are the center coordinates; r is the radius. Because the coordinate origin (0, 0) sits at the extreme upper-left, a circle needs cx/cy equal to at least its own radius to be fully visible within the canvas rather than clipped by the top/left edges.
Multiple circles, correctly spaced:
<circle cx="100" cy="100" r="100" />
<circle cx="100" cy="300" r="100" />
<circle cx="100" cy="500" r="100" />
A common mistake: incrementing the cy value by the radius (100) instead of the full diameter (200) between successive circles, which causes them to overlap — circles need spacing equal to twice the radius to sit flush against each other with no gap or overlap.
Rectangle:
<rect x="0" y="0" width="150" height="50" fill="orange" transform="rotate(15)" />
Text (a genuine first-class SVG element, not something bolted on from outside):
<text x="50" y="100" fill="black">Hello</text>
Styling SVG elements: fill, not background-color
SVG elements are HTML elements only in a loose sense — they follow their own styling rules in places. Coloring an SVG shape uses the fill attribute (or the fill CSS property), not background-color:
circle {
fill: red; /* NOT background-color, which has no effect on SVG shapes */
}
The same applies to text inside SVG: styling it with the CSS color property has no effect; fill is required there as well.
Transforms: scale, translate, and groups
<g transform="scale(2)">
<circle cx="100" cy="100" r="50" />
<text x="50" y="100">Label</text>
</g>
scale(), translate(), and matrix() (a combined transform expressing more than one operation — e.g., scale plus rotate plus translate — at once) all manipulate the coordinate system, not just visual size. Scaling an individual element also scales its own position coordinates relative to the SVG’s origin, which can look surprising until you group related elements together with <g> — grouping and transforming the group keeps everything visually consistent relative to each other, the same way zooming an entire page keeps every element’s relative position sensible even though each one’s absolute coordinates all shift together.
Individual elements inside a group can still carry their own additional transforms on top of the group’s shared transform (e.g., nudging just the text a bit further right/down relative to its sibling shape).
Paths: the general-purpose drawing primitive
<path d="M400,0 L280,150 L500,150 Z" fill="none" stroke="blue" stroke-width="12" />
The d attribute is a compact command language:
| Command | Meaning |
|---|---|
M x,y | Move to (starting point, doesn’t draw) |
L x,y | Draw a straight line to this point |
C ... | Draw a cubic curve, defined by a start point, an end point, and control point(s) that bend the curve between them |
Z | Close the path — draw a final implicit line back to the starting point, forming a closed polygon |
With only M and one L, nothing visible appears (a path needs at least three points/vertices to enclose an area). Adding more L commands builds up a polygon (a triangle, then a quadrilateral, then a pentagon, and so on). Omitting Z leaves the path open — only the explicitly drawn line segments appear, with no implicit closing segment back to the start; setting fill="none" combined with an explicit stroke color/width draws just the outline rather than a filled shape.
Practical guidance on authoring SVG
Manually puzzling out coordinates for anything beyond a very simple path is impractical. Realistic complex SVG artwork (like a detailed illustration) is typically authored visually in a tool such as Illustrator and then exported as SVG — that’s a completely legitimate way to get production-quality static SVG.
For data-driven SVG specifically (charts, graphs, any visualization whose shape depends on live data), hand-coding raw SVG primitives is not the practical approach either. A dedicated library such as D3 (Data-Driven Documents) abstracts away almost all of this manual coordinate math and is the recommended tool once you move from static illustration into genuine data visualization.
Module 15: Controlling Capitalization with CSS
Why control casing in CSS rather than in the content itself
Relying on whoever (or whatever process) creates the markup to always type the correct casing is fragile. There are situations where only one specific casing is ever correct regardless of what was entered, and CSS can enforce that centrally rather than depending on discipline at content-entry time.
text-transform: the three practical values
h1 { text-transform: capitalize; } /* Capitalizes the First Letter Of Every Word */
p { text-transform: lowercase; } /* forces every letter to lowercase */
p { text-transform: uppercase; } /* FORCES EVERY LETTER TO UPPERCASE */
| Value | Effect | Caution |
|---|---|---|
capitalize | Capitalizes the first letter of every word | Capitalizes every word, including articles/prepositions that proper title case would leave lowercase |
lowercase | Forces all text to lowercase | — |
uppercase | Forces all text to uppercase | Reads like shouting in normal body copy; reserve for genuinely appropriate use cases |
Why there’s no CSS “title case” value
This is a deliberate, principled gap in CSS. Title case is a subset of capital casing where some words are capitalized and others (typically short articles, conjunctions, and prepositions) are not — but professional style guides don’t even agree on the exact rule. The Chicago Manual of Style says don’t capitalize minor words like prepositions, conjunctions, and articles; the AP Style guide mostly agrees, except that it capitalizes any word longer than four characters regardless of part of speech. The same title can legitimately render differently under each style guide, and neither is “wrong.”
Because correct title casing depends on a word’s part of speech in context (a short word might be a preposition in one sentence and a proper noun/title in another), and CSS has no concept of grammar or part-of-speech, text-transform fundamentally cannot implement true title casing — capitalize is the closest approximation CSS can offer, and it capitalizes indiscriminately, including words a real style guide would leave lowercase.
Practical guidance: don’t try to solve title casing with styling or code. If it matters for a given piece of content, have a human editor check it directly rather than chasing an automated rule that can’t actually be correct in every case. (You can write a script that checks candidate title strings against a dictionary of known minor words, but even that requires accepting some edge cases and doesn’t remove the need for a human sanity check.)
First-letter capitalization and drop caps
CSS provides a ::first-letter pseudo-element, though there’s no equivalent pseudo-class for “first letter of every sentence” — that would require actual sentence-boundary parsing, which is outside CSS’s scope (though scriptable, by wrapping each sentence in its own <span>).
A classic print-style presentation, the drop cap, builds directly on ::first-letter:
p::first-letter {
font-size: 300%;
font-weight: bold;
float: left; /* the key property — without it, the drop cap won't "drop" into the paragraph */
padding-right: 4px;
line-height: 1;
}
The counter-intuitive part: without float: left, the enlarged first letter simply inflates in place rather than visually “dropping” down alongside the following lines of text. float is what reserves the horizontal space next to the enlarged letter for the following text to wrap around.
The deeper distinction: orthography vs. styling
Orthography — writing according to established rules and conventions so a reader can correctly decode the intended message — is a fundamentally different concern from styling, which controls visual presentation. Tense agreement in a sentence (e.g., mixing past and present tense incorrectly) is an orthographic problem, not a styling problem — there’s no meaningful CSS property that could “fix” that, because there’s no alternate valid value; it’s either correct or it isn’t.
text-transform sits right on the boundary between these two concerns, and the title-case situation is exactly where that tension becomes visible: CSS is fundamentally not a tool for fixing bad writing. If you’re tempted to paper over inconsistent capitalization in user-generated content using CSS, a proofing/validation tool that catches the problem at the orthographic level and prompts a correction is the right tool — not a styling rule.
Module 16: Making Links Target Different Media and Devices
Beyond the plain hyperlink
An <a href="..."> is familiar territory, but the href protocol scheme itself can target far more than web pages — invoking the appropriate app on the user’s device instead.
Phone numbers: the tel: protocol
A plain phone number rendered as ordinary text is a real usability problem on mobile: the user has to triple-tap to select it, or fiddle with selection handles, just to copy it into the phone app — a task tedious enough that many users will simply give up rather than bother.
<a href="tel:+15551234567">+1 (555) 123-4567</a>
Clicking/tapping this link directly invokes the device’s phone app pre-filled with the number — no manual copy/paste required. Including the leading country code (+1 for the US in this example) is a small but important detail: without it, the number only works correctly when dialed from within that country’s phone system; a user calling internationally would fail to connect.
Email addresses: the mailto: protocol
<a href="mailto:contact@example.com">contact@example.com</a>
This invokes the user’s configured mail application with the address pre-filled, avoiding a manual copy/paste into a separate mail client — useful on both mobile and desktop.
Recommendation: only ever wrap the literal, visible email address text in a mailto: link, rather than a generic “Contact Us” link pointing at the same address. If you need a richer, more general contact experience (capturing a subject, structured questions, multiple recipients), build an actual HTML form that sends the email server-side instead — mailto: links can carry a subject line and multiple recipients via query parameters, but a real form gives far more control over the interaction.
SMS: the sms: protocol
<a href="sms:+15551234567">Text us</a>
For audiences who’d rather text than call, sms: invokes the device’s messaging app instead of the phone app — a small but meaningful difference in the resulting user action.
Other app-targeting protocols
Protocol handlers are largely arbitrary and app-defined — any protocol a user’s installed software registers itself to handle can be targeted this way, accepting the risk that not every user will have a matching app installed:
<a href="skype:example.user?call">Call on Skype</a>
(Legacy protocols like irc: and ftp: follow the same general pattern, for anyone still supporting those workflows.)
Print-specific stylesheets
The media attribute on a <link> lets an entirely separate stylesheet apply only in a specific output context:
<link rel="stylesheet" href="main.css">
<link rel="stylesheet" href="print.css" media="print">
A page styled with a colored background and background image looks fine on screen but wastes ink/toner and looks poor when printed. A dedicated print stylesheet can strip exactly the properties that don’t belong in a printed context:
/* print.css */
body {
background-color: transparent; /* NOT "none" -- that's not a valid value and will be ignored */
background-image: none;
}
A specific gotcha: setting background-color: none !important does not work — none is simply not a valid value for background-color, so the (invalid) declaration is ignored entirely, even with !important, and the previous color value silently remains in effect. The correct value to remove a background color is transparent.
Debugging print styles without actually printing
Chrome DevTools lets you emulate the print media type directly, without repeatedly opening the print dialog: open the More tools → Rendering panel, then set Emulate CSS media type to print. This renders the page as it would appear for print while keeping the full interactive DevTools available for inspection — invaluable for catching exactly this kind of invalid-property mistake, which DevTools flags with a warning icon next to the offending declaration.
Adding print-only content
A print stylesheet isn’t limited to hiding things — it can also add content meant only for a printed copy, such as a note about where the printout originated and where to find more information online, visible only when the page is printed (or print-emulated), and invisible in normal screen viewing.
Why this matters in practice
A well-designed print stylesheet can save enormous amounts of engineering effort. In one real scenario, a benefits-communication web portal needed to also produce paper statements for field employees without reliable internet access. Rather than rebuilding the entire statement-generation logic as a separate system, a combination of (1) a dedicated print stylesheet, (2) a custom print-only cover sheet, and (3) a browser automation script (using a tool like Selenium to drive logins and trigger printing for each employee from a spreadsheet of accounts) turned what would otherwise have been a multi-week rebuild into roughly a day and a half of setup and execution.
Module 17: Replacing Zombie Tags with Something Better
A brief history: presentation baked into content
HTML evolved incrementally, starting without even tables or a working form model. Styling itself only arrived in version 3 (1997), as inline styles only — no external stylesheets yet. HTML 4 later split styles out into separate, properly maintainable stylesheets, formalizing the now-central principle: separate presentation from content. Older HTML predates that principle entirely, which is why a number of old tags and old usage patterns still linger past the point where they make sense, given today’s much more varied device/presentation landscape.
Tags to avoid outright
| Tag | Problem |
|---|---|
<marquee> | Produces scrolling text, which is inherently hard to read and can force users to wait for content to scroll back into view. The problem is scrolling text itself — reimplementing the same effect with script doesn’t fix the underlying readability problem. |
<blink> | Intended to draw attention to something important, but became universally disliked in practice and was abandoned relatively quickly after browsers implemented it. |
<applet>, <object> (for ActiveX), <embed> (for legacy plugin content) | Stopgap ways of embedding rich functionality HTML couldn’t otherwise support — sandboxed Java applets, Microsoft’s ActiveX controls (with an inadequate security sandbox), and generic embedded plugin content. All were too early, too risky, and too resource-heavy relative to what HTML/CSS/script eventually grew to support natively, and were deprecated as the platform matured. |
The <center> tag
<!-- Old -->
<center>
<div id="main">...</div>
</center>
<!-- New -->
<div id="main">...</div>
#main {
text-align: center; /* if simple content centering was the actual intent */
/* or, more likely, what the original author actually wanted: */
margin: 0 auto;
width: 600px;
}
The <center> tag is semantically empty — it communicates a purely visual instruction baked directly into the markup, with no equivalent representation for, say, an auditory screen-reader presentation, where “centered” carries no meaning at all. Removing the tag and expressing the same effect in CSS (via text-align: center on a container, or more often the combination of margin: 0 auto + an explicit width if the original intent was actually a centered content column with left-aligned inner content) preserves the same visual result while keeping the styling decision separate from the content.
<b> and <i> → <strong> and <em>
<!-- Old -->
<b>Important</b>
<i>Aside</i>
<!-- New -->
<strong>Important</strong>
<em>Aside</em>
As covered in Module 9, these communicate meaning (importance, emphasis) rather than raw typography (bold weight, italic slant) — while still rendering with the same default bold/italic appearance in most browsers. Because the visual mapping is just a default, it can be freely overridden without losing the semantic meaning:
strong {
font-size: 150%;
font-weight: normal; /* explicitly turn OFF bolding -- "strong" now means "big", not "bold" */
}
em {
color: firebrick; /* explicitly turn OFF italics -- "emphasis" now means "colored", not "italic" */
font-style: normal;
}
Emphasizing without relying on color alone
If emphasis is communicated purely through color, roughly 8–10% of users (those with red/green color blindness) may miss it entirely. The little-known text-emphasis property adds a visible emphasis mark directly to the glyphs themselves, independent of color:
em {
text-emphasis: double-circle purple;
}
The <strike> tag → a semantic, purpose-named class
There’s no direct semantic tag equivalent for “struck-through” text, so the right fix recasts the content as a <span> with a class describing why the text is struck through — not merely restating the visual effect:
<!-- Old -->
<strike>redacted phrase</strike>
<!-- New: name the class after the *reason*, not the *visual effect* -->
<span class="redacted">redacted phrase</span>
.redacted {
text-decoration: line-through;
}
Naming the class strike or strikethrough would just repeat the mistake of mixing presentation into the content’s naming — redacted communicates the actual reason the text is marked this way (e.g., representing text removed from a source document), which is far more useful to a future maintainer than a name that just restates “this has a line through it.”
A more elaborate, stylized “government document redaction” look can be built directly on top of the same class:
.redacted {
color: black;
background-color: black;
border: 1px solid gray;
}
Should <strong>/<em> themselves ever become classed spans?
Generally, no — recasting <strong> or <em> as <span class="strong">/<span class="em"> adds keystrokes without adding any semantic value beyond what the native tag already communicates. The only case where a more specific class name adds real value is when it communicates why something is emphasized or important beyond the generic native meaning — simply repeating the same idea with more code isn’t worth it.
Zombie tag replacement reference
| Old tag / pattern | Problem | Modern replacement |
|---|---|---|
<marquee> | Scrolling text is inherently hard to read | Avoid the pattern entirely (script or CSS) |
<blink> | Universally disliked, ineffective at drawing attention | A deliberate, sparing CSS animation (Module 12) if truly needed |
<applet> / <object> / <embed> (legacy plugin use) | Insecure, resource-heavy, obsolete | Native HTML5 features (video/audio/canvas/SVG) or modern script |
<center> | Presentation baked into content, semantically empty | text-align: center, or margin: 0 auto + width |
<b> | Communicates typography, not meaning | <strong> (+ CSS override if a different visual is desired) |
<i> | Communicates typography, not meaning | <em> (+ CSS override if a different visual is desired) |
<strike> | No semantic equivalent; conflates cause and effect | <span class="reason-for-strikethrough"> + text-decoration: line-through |
Module 18: Restricting File Types in Upload Inputs
Why restrict upload file types
Any file upload endpoint accepts a class of risk: a malicious file could later be leveraged as a payload if some other server-side vulnerability lets it get executed, or a user’s private data could otherwise end up somewhere it shouldn’t. Full protection requires server-side hardening and scanning — outside the scope of this playbook — but restricting the type of file a form will even offer for selection eliminates an entire class of accidental problems, and matches the everyday desktop-app expectation that an “Open” dialog only shows files relevant to the current operation (e.g., a word processor’s Open dialog doesn’t clutter the list with unrelated image or code files).
The accept attribute
<input type="file" accept="image/*">
It might seem natural to filter by file extension (e.g., .jpg, .png), and browsers do technically support that. However, the HTML specification itself explicitly warns against relying on extensions:
Extensions tend to be ambiguous… and users can typically quite easily rename their files to have a given extension, even if they are not actually files of that type… MIME types also tend to be unreliable, since many formats have no formally registered type. Authors are reminded that data received from a client should always be treated with caution, as it may not be in the expected format, even if the
acceptattribute’s requirements were fully honored by the user agent.
A vivid illustration of why extensions alone are unreliable: modern Office documents (.docx, .pptx, .xlsx) are, in their actual underlying file format, ZIP archives. Renaming any of them to .zip and opening them as an archive works — because that is their real container format, regardless of what the visible extension says.
Instead, the accept attribute is meant to work primarily with MIME types, per the spec’s guidance that valid values are audio/*, video/*, image/*, or an explicit MIME type string:
<!-- Restrict to any image type -->
<input type="file" accept="image/*">
<!-- Restrict to a specific spreadsheet MIME type (legacy .xls format) -->
<input type="file" accept="application/vnd.ms-excel">
<!-- Restrict to multiple explicit MIME types, comma-separated -->
<input type="file" accept="application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet">
Different spreadsheet format generations use different MIME types (the legacy binary .xls format vs. the newer XML-based .xlsx format) — accepting only one of them will silently exclude files saved in the other format, so multiple explicit MIME types are often needed together to cover the realistic range of files a user might have.
What accept actually protects against — and what it doesn’t
The spec’s own guidance is explicit that this filtering is not a meaningful security boundary — think of it as roughly equivalent to a little brass chain latch on a door: it stops innocent, accidental mistakes, but does nothing against even a mildly determined attacker. A user restricted to image/* in the file picker UI can still manually type a filename with any extension into the file selection dialog, or simply rename a malicious payload file to end in .jpeg before selecting it — nothing in the accept attribute itself validates that the file’s actual content matches its claimed type once it’s selected and submitted.
What accept genuinely provides:
- A meaningfully better user experience — the file picker only visibly offers relevant files by default.
- A basic reduction of accidental mismatches (e.g., preventing an unintentional spreadsheet upload on an image-only form).
What it does not provide:
- Any real guarantee about the actual content/type of the uploaded file.
- Any protection against a deliberate attacker who can trivially rename a file or bypass the picker’s suggested filter.
Genuine content-type validation has to happen server-side (verifying actual file signatures/content, not just the client-reported Content-Type header or filename extension), which is outside the scope of what pure HTML can offer.
flowchart TD
A["input type=file accept=...";] --> B[Improves UX: picker shows<br/>only relevant files by default]
A --> C[Blocks *accidental*<br/>wrong-type uploads]
A -.->|Does NOT provide| D[Any real security guarantee]
D -.-> E[Attacker can rename any file<br/>to a permitted extension]
E --> F[Real protection requires<br/>server-side content validation]
Module 19: Selecting Multiple Files with Upload Inputs
The single-file default
<input type="file">
By default, a file input only permits selecting a single file at a time — even holding a modifier key like Ctrl while clicking additional files in the OS file picker doesn’t override this; the browser simply won’t allow more than one selection.
Enabling multi-file selection
<input type="file" multiple>
Adding the boolean multiple attribute allows the OS file picker to accept more than one file selected at once (e.g., via Ctrl-click or a drag-select), all submitted together as part of the same form field.
Whole-directory selection (non-standard, use with caution)
<input type="file" webkitdirectory directory>
Adding these (empty/boolean) attributes allows selecting an entire folder’s worth of files at once. This works in Chrome today, but the webkitdirectory naming itself signals its non-standard, vendor-prefixed status — it is not a fully adopted part of the HTML standard, support across browsers (particularly Safari) is inconsistent, and the underlying proposal has lingered without full standardization for a long time. Recommendation: avoid depending on this in production unless you’ve specifically verified support across every browser your audience actually uses.
A real-world design alternative: accept a ZIP instead
One organization managing a corporate wellness/step-tracking program with user-uploaded photos ran into exactly this multi-file limitation years before the multiple attribute (and long before webkitdirectory) was reliably available. Users wanted to upload many photos from a group event at once. The solution that shipped: let users upload a single ZIP file of their images, and unzip it server-side automatically.
This had two real benefits beyond just working around the missing browser feature:
- It solved the immediate practical (technical) limitation cleanly.
- Requiring users to deliberately assemble a ZIP file first introduces a small but meaningful moment of friction and reflection before uploading — reducing the chance of accidentally selecting and sending the wrong files or personal data that was never meant to be shared. (In this specific program, a mismatch between what a user intended to upload and what a login was tied to had already caused a real moderation problem, underscoring the value of that extra deliberate step.)
Practical guidance
| Approach | Standardization | Reliability | When to use |
|---|---|---|---|
multiple attribute | Fully standard | High | Default choice whenever users need to select more than one file |
webkitdirectory/directory | Non-standard, vendor-prefixed | Inconsistent (notably Safari) | Avoid unless you’ve explicitly verified support for your actual audience |
| ZIP-upload-and-unzip pattern | N/A (an application-level design pattern) | High (once server-side unzip logic exists) | Bulk multi-file uploads, especially where a deliberate “packaging” step also adds a useful moment of user reflection |
Module 20: Creating Simple Data Visualization with HTML and CSS Only
This module deliberately pushes HTML and CSS into a role they weren’t really designed for — visualizing numeric data — purely to explore the boundary of what’s possible without any script, and to make the case for when a real visualization library becomes the better tool. Throughout, “chart” is used deliberately rather than “graph” (in data science, “graph” refers to a specific different structure — nodes and edges — so calling these charts avoids that ambiguity, even though the distinction is often blurred colloquially).
A markup convention: keep the real data in the markup
A good habit for any data-driven visualization: always keep the actual underlying data values present in the markup itself (e.g., as the literal text content of each data element), even if it’s not the primary visual presentation — this keeps the source of truth visible and inspectable rather than hidden away purely in CSS class names or computed styles.
<!-- Data: first four Fibonacci numbers -->
<div class="chart">
<div class="bar bar1">1</div>
<div class="bar bar2">1</div>
<div class="bar bar3">2</div>
<div class="bar bar4">3</div>
</div>
Building a bar chart
Starting from stacked blocks, a horizontal row layout uses flexbox on the container (not the individual bars):
.chart {
display: flex; /* on the parent container, not the bars themselves */
height: 300px;
}
Each bar’s height is driven by its value, with specificity raised enough to override the container’s own height rule:
.chart .bar1 { height: 24px; background-color: #4477aa; }
.chart .bar2 { height: 24px; background-color: #66aadd; }
.chart .bar3 { height: 48px; background-color: #33bb99; }
.chart .bar4 { height: 72px; background-color: #ee9944; }
Bars conventionally rise from the bottom rather than the top:
.bar {
margin-top: auto; /* pushes each bar down to the bottom of the flex container */
color: white;
text-align: center;
font-size: 1.2em;
}
A subtle but important trap: padding lies about the data. Adding padding-top to push the value label down off the very top edge of each bar also increases the bar’s total rendered height, which visually — if only slightly — understates the real difference between values. This is a real technique sometimes used (deliberately or not) to mislead in data visualizations. Guidance: don’t use padding to adjust a data-driven dimension’s apparent size; if labels need repositioning, do it without altering the property that’s actually encoding the data value.
A vertical bar chart is a simple axis swap — switch the flex direction and drive width instead of height:
.chart {
display: flex;
flex-direction: column;
}
.bar1 { width: 24px; }
Building a pie chart
Pie charts come with real, well-known caveats: they’re a circular (radial) representation forced onto a rectangular display (wasting pixels), very small slice values tend to disappear or become unreadable, and labeling is often awkward. General recommendation: prefer a stacked bar chart over a pie chart wherever practical. With that said, here’s how to build one in pure CSS when a pie chart is specifically required:
<div class="pie">
<span class="value1">1</span>
<span class="value2">1</span>
<span class="value3">2</span>
<span class="value4">3</span>
</div>
The conic-gradient CSS function draws a gradient oriented circularly around the element’s center — exactly what a pie slice needs:
.pie {
width: 300px;
height: 300px;
border-radius: 50%; /* the trick that turns a circular gradient square into an actual "pie" */
background: conic-gradient(
#4477aa 0deg 24deg, /* value 1 of 4: (1/15) * 360 = 24deg */
#66aadd 24deg 48deg, /* value 1 of 4: another 24deg */
#33bb99 48deg 144deg, /* value 2 of 4: (4/15) * 360 = 96deg, running from 48deg to 144deg */
#ee9944 144deg 336deg /* value 4 of 4: (8/15) * 360 = 192deg, running from 144deg to 336deg */
);
}
Converting proportional data values into degrees requires simple arithmetic: sum all the values, divide 360 by that sum to get “degrees per unit,” then multiply each value by that factor to get its individual slice’s angular width, accumulating the running total as the starting angle for the next slice. For example, with values 1, 2, 4, 8 (sum = 15): 360 ÷ 15 = 24 degrees per unit, so the four slices span 24°, 48°, 96°, and 192° respectively.
border-radius: 50% on a square element is what actually turns the square conic-gradient box into a circular pie — without it, you’d just have a square filled with radial color bands. Slice labels need to be manually positioned using basic trigonometry (converting each slice’s midpoint angle and the desired radius into x/y offsets) — genuinely more work than it might first appear, and one more reason pie charts are more labor-intensive than bar charts to build well.
Building a line chart (conceptually)
A pure-CSS/HTML line chart is achievable (historically demonstrated using absolutely positioned marker and connector images/elements, one pair per data point, each positioned via manually computed x/y coordinates), but at this point the amount of manual coordinate math required — for every single data point and every connecting line segment — makes the pure-CSS approach genuinely impractical to maintain, especially compared to a scripted or library-driven approach.
When to stop doing this by hand
All three chart types above required deliberately performing geometry/trigonometry by hand specifically to avoid any script — a genuinely interesting exercise, but not a realistic recommendation for production work beyond the simplest, most static of cases. Once even a small number of comparable visualizations are needed, or the data is not static, a small amount of script (even just plain JavaScript’s built-in math functions) removes the need to hand-derive coordinates, and a dedicated visualization library (such as D3.js) goes much further still — handling everything from simple bar/line/pie charts up to fully bespoke, highly polished visualizations, at a fraction of the effort pure CSS would require.
flowchart TD
A[Need a chart] --> B{How many charts,<br/>and is data static or dynamic?}
B -->|One, ever, totally static| C[Pure CSS/HTML technique<br/>is plausible, if labor-intensive]
B -->|More than one, or data changes| D[Use script — at minimum<br/>plain JS math, ideally a library]
D --> E[Consider a dedicated<br/>visualization library like D3.js]
Chart-type comparison
| Chart type | Pure CSS/HTML feasibility | Key CSS mechanism | Main caveats |
|---|---|---|---|
| Bar chart | Reasonable for static, simple cases | display: flex on container + per-bar height/width | Avoid using padding to reposition labels — it distorts the encoded value |
| Pie chart | Possible but labor-intensive | conic-gradient + border-radius: 50% | Manual degree math per slice; manual trig for label placement; inherent pie-chart readability caveats |
| Line chart | Impractical beyond a trivial example | Absolutely positioned marker/line elements per data point | Requires manual x/y coordinate math for every point and segment |
Module 21: Taking Control Over Mobile Presentations with Media Targets
The core problem: one codebase, many viewports
Designing purely for a desktop-sized viewport, then discovering the same page is largely unusable on a phone, is a common and avoidable failure mode. A particularly important point: you don’t develop on the device you’re designing for — a desktop development setup (large monitors, wide aspect ratio) is a poor proxy for the much narrower, taller viewport most visitors will actually use.
An older, now-discouraged approach to this mismatch was maintaining entirely separate markup for different browsers/devices — a pattern common in the early days of severe cross-browser incompatibility. In practice, multiple parallel copies of the same content are effectively impossible to keep in sync over time and reliably drift apart, causing embarrassing inconsistencies. The durable answer is a single copy of the markup, with presentation differences handled entirely through CSS media queries.
Media queries: the basic mechanism
@media screen and (max-width: 799px) {
.table-wrapper table {
display: none;
}
}
Media queries generally target dimensions, not specific named devices — the goal is “when the viewport is narrower than X” or “when printing,” not “when this is a Google Pixel.” Media queries cannot be expressed as inline styles; they require an external (or at least a document-level <style> block containing an) @media rule.
Required companion: the viewport meta tag
<meta name="viewport" content="width=device-width, initial-scale=1">
Without this meta tag, mobile browsers default to rendering the page at a desktop-simulated width and then zooming out to fit — which defeats the purpose of any responsive media queries, since the browser never actually reports the narrow viewport width your media queries are trying to detect. This tag resets that default behavior so the reported viewport width genuinely matches the physical device width.
Example 1: hiding content below a breakpoint
A dense data table might become illegibly small once squeezed into a narrow mobile viewport. Testing against several device widths (checking a phone in portrait, an iPad Mini, an iPad Air) can establish a practical breakpoint — for example, discovering that content becomes reasonably legible again starting around an ~800px-wide viewport:
@media screen and (max-width: 799px) {
.data-table { display: none; }
}
Rotating a narrow device to landscape orientation can push its effective width past the breakpoint, correctly restoring the table — confirming the media query is genuinely responding to available width, not to a specific device identity.
Example 2: a less drastic alternative — resize instead of hide
Outright hiding content is a fairly blunt tool. A softer alternative simply increases font size on narrow viewports rather than removing the content entirely:
@media screen and (max-width: 799px) {
.data-table { font-size: 1.3em; }
}
The media attribute isn’t limited to screen
The same mechanism used for responsive breakpoints also drives print-specific presentation (Module 16):
@media print {
.data-table { display: none; } /* hide something irrelevant once actually printed */
}
Any interactive chrome (navigation, menus) that’s meaningless once printed is a natural candidate for being hidden this way, leaving a clean, article-only printed page — a courtesy relatively few sites actually bother to implement.
Tables built from <div>s for full responsive control
An interesting technique: two visually identical “tables” can be built completely differently under the hood — one using literal <table> markup, and a second built entirely from <div>s laid out with display: flex (or similar) to visually mimic a table’s row/column structure. The div-based version can be restyled into an entirely different layout at different breakpoints (e.g., collapsing rows into stacked cards on narrow viewports) in ways that are difficult or impossible to achieve by reflowing a literal <table> element, which carries its own built-in browser layout defaults that have to be actively overridden to behave otherwise. This flexibility is exactly why responsive design techniques often favor div/flex-based layout structures over literal table markup for anything that needs to look meaningfully different across breakpoints — with the tradeoff that you take on the responsibility of reproducing whatever built-in table behavior you actually still need.
Media query decision guide
flowchart TD
A[Content doesn't fit / doesn't<br/>read well at some viewport size] --> B{What's the best fix<br/>at the narrow breakpoint?}
B -->|Content is genuinely unusable<br/>at small size| C["display: none inside<br/>a max-width media query"]
B -->|Content is usable but<br/>hard to read| D["Increase font-size inside<br/>a max-width media query"]
B -->|Need a fundamentally<br/>different layout shape| E[Rebuild the structure with<br/>div/flex instead of literal table]
A --> F{Is this actually about<br/>print, not screen width?}
F -->|Yes| G["@media print rule instead<br/>of a width-based query"]
Why a UI framework helps here too
Undoing a browser’s own built-in default behaviors (for tables, forms, and other native elements) in order to make them fully responsive is real, recurring work. This is exactly the kind of problem a mature UI framework like Bootstrap (Module 11) solves once, comprehensively, on your behalf — exposing responsive behavior through simple classes/attributes rather than requiring you to hand-roll your own breakpoint system from scratch. General guidance: know how media queries work well enough to override or extend a framework’s behavior when genuinely needed, but avoid building a fully custom, from-scratch media-query system as your primary approach — lean on a proven framework for the bulk of that responsibility instead.
Summary
Core principles that recur throughout this playbook
- Prefer semantic, standards-based solutions over clever hacks. “Tricks are for kids” — code that relies on browser quirks or non-obvious side effects is brittle and hard to maintain; code that expresses its actual intent (via the right tag, the right property) is comprehensible to the next developer, is friendlier to accessibility tooling, and tends to survive rendering-engine changes far better.
- Separate content from presentation. This is the single idea that HTML 4 introduced (externalized stylesheets) and that a long tail of “zombie tags” (
<center>,<b>/<i>used presentationally,<marquee>,<blink>) violates. Presentation belongs in CSS; meaning belongs in the markup. - Contrast is the unifying accessibility principle. Whether it’s avoiding text-in-images, never relying on color alone, using relative font units, or ensuring sufficient color contrast, the underlying goal in every case is making content perceivable to the widest practical audience.
- Never communicate anything — meaning, emphasis, or data — through color alone. Roughly 8–10% of any general audience has some form of color blindness, most commonly red/green. Pair color with a second cue (icon, label, pattern, text).
- Measure before optimizing. CSS image sprites are the clearest example: the technique can help, do nothing, or actively hurt, depending on the specific images involved — always verify with real request/payload measurements rather than assuming a technique is a universal win.
- Know when to stop avoiding script. The “no script” constraint throughout this playbook was a deliberate teaching device, not a real-world rule. Nearly every technique here — from capitalization to data visualization to animation — genuinely improves with a modest, deliberate amount of scripting once complexity crosses a certain threshold. Recognizing that threshold is itself a practical skill.
- A UI framework is a force multiplier, not a crutch. Frameworks like Bootstrap package up recurring, well-solved problems (consistent cross-browser defaults, responsive tables/forms, accessibility-conscious form patterns) so you aren’t reinventing them on every project.
- Lean on reference tools rather than memorizing browser quirks. HTML5test.com and CanIUse.com exist precisely so compatibility questions can be answered quickly and confidently, rather than guessed at or discovered painfully in production.
Quick-reference: which module solves which problem
| If you need to… | See |
|---|---|
| Keep a growing stylesheet organized and DRY | Module 2 (Sass) |
| Center something horizontally or vertically | Module 3 |
| Build an expand/collapse menu with no script | Module 4 |
| Make a page usable for screen readers, color-blind users, or low-vision users | Module 5 |
| Improve plain-text legibility | Module 6 |
| Load a custom typeface reliably | Module 7 |
| Validate/mask a form field using only HTML | Module 8 |
| Make markup meaningful to developers, assistive tech, and search engines | Module 9 |
| Decide whether a feature is safe to use for your audience | Module 10 |
| Get a consistent, responsive baseline quickly | Module 11 (Bootstrap) |
| Draw the user’s eye or add a decorative motion effect | Module 12 |
| Cut down on many small, repeated image requests | Module 13 |
| Draw scalable, resolution-independent graphics | Module 14 (SVG) |
| Enforce consistent capitalization | Module 15 |
| Make phone numbers, emails, or texts one-tap actionable, or produce a clean printed page | Module 16 |
| Replace an old/deprecated tag with a modern equivalent | Module 17 |
| Limit the file types a form will accept | Module 18 |
| Let users upload more than one file at once | Module 19 |
| Build a simple chart without a charting library | Module 20 |
| Make one codebase work well across desktop, mobile, and print | Module 21 |
Closing checklist
- Is presentation kept out of the markup (no
<center>, no font-only<b>/<i>, no inline “style hacks”)? - Does every structural region use the most meaningful available semantic element rather than a generic
<div>? - Is any information (danger/safety, chart values, emphasis) ever communicated through color alone?
- Are font sizes expressed in relative units (
rem) rather than fixed points/pixels? - Have you actually measured whether an optimization (like image sprites) is helping, rather than assuming it is?
- Have you checked real support data (HTML5test.com / CanIUse.com) before shipping a newer or less common feature?
- Does the page behave sensibly at a realistic range of viewport widths, not just your development monitor?
- If printed, does the page shed irrelevant chrome and colors rather than wasting ink and looking broken?
- Are file uploads restricted by MIME type via
accept, with the understanding that this is a UX/accidental-misuse safeguard, not a security control? - Once a technique’s complexity grows past a simple, static case, have you considered whether a small amount of script (or a dedicated library) is now the more practical choice?
Search Terms
html · css · playbook · web · fundamentals · frontend · development · practical · content · control · elements · media · sass · semantic · bootstrap · font · fonts · input · method · styling · svg · tag · types · accept