guides/accessibility/accessibility.md
# Accessibility Coding Guidelines
This guide provides actionable DOs and DON'Ts for AI coding agents to ensure web applications are accessible to all users, including those using assistive technologies.
Keep these principles in mind throughout:
- **Accessibility is the minimum, not the ceiling.** Conformance to standards is the floor; aim for genuine usability.
- **Patterns are use-case specific.** No checklist replaces real testing — including testing with disabled users — to confirm a given implementation is actually accessible in context.
## 1. Content Navigability and Structure
### Actionable Guidelines
#### DOs
- **Place all content within landmarks**: Wrap each relevant part of the page in `<header>`, `<nav>`, `<main>`, `<aside>`, and `<footer>` elements so assistive-tech users can jump between regions.
- **Structure main content with headings**: Use `<h1>`–`<h6>` sequentially (no jumping `<h1>` → `<h4>`) so screen-reader users get a navigable outline.
- **Use lists for repeated, contiguous content**: `<ul>`/`<ol>` give assistive tech a count up front and let users skip the entire group.
- **Provide skip links** prior to repeated content like site headers with navigation or long/infinite lists, so that keyboard users can easily bypass them. Make sure the target is focusable (e.g. `<main id="content" tabindex="-1">`).
- **Semantic Tables**: Use `<caption>` and `<th scope="col">` (or `<th scope="row">`) for data tables.
#### DON'Ts
- **Don't use fake headings**: Never style `<div>` or `<span>` to look like headings without standard `<h1>`–`<h6>` tags.
- **Don't place headings inside `<summary>`, and avoid relying on headings inside `<details>` content**: Headings inside `<summary>` may be hidden from screen-reader heading lists and heading-navigation shortcuts entirely; headings inside `<details>` content are only reachable via heading navigation when the disclosure is open.
- **Caveat**: If a heading must act as a disclosure trigger, use a more robust alternative to `<details>`/`<summary>` instead, e.g. an accordion or a disclosure implemented with ARIA where the heading wraps the button.
- **Don't use tables for layout**: Use CSS Grid/Flexbox for visual layouts.
- **Don't overuse landmarks**: Too many landmarks dilute their value. In particular, avoid labeling a `<section>` (which turns it into a `region` landmark) — `region` should be a last resort when no other landmark fits.
### Code Examples
```html
<!-- Good: Semantic landmarks, heading hierarchy, skip link -->
<header>
<a href="#content" class="skip-link visually-hidden">Skip to content</a>
<nav aria-label="Primary">
<ul>
<li><a href="/">Home</a></li>
</ul>
</nav>
</header>
<main id="content" tabindex="-1">
<h1>Platform Dashboard</h1>
<section>
<h2>User Statistics</h2>
<table>
<caption>Monthly active users</caption>
<tr>
<th scope="col">Month</th>
<th scope="col">Users</th>
</tr>
<tr>
<td>January</td>
<td>12,000</td>
</tr>
</table>
</section>
</main>
```
## 2. Semantic HTML and ARIA
### Actionable Guidelines
#### DOs
- **Prefer HTML elements and attributes to ARIA**: A native element comes with the right role and behavior. `<button>` already implies `role="button"`; `required` already implies `aria-required`.
- **Match ARIA implementations to actual behavior**: If you set `role="tab"`, the element must behave like a tab — including keyboard interactions. Many ARIA patterns can't be implemented in CSS alone and need JavaScript.
- **Be deliberate about `disabled` vs `aria-disabled`**: `disabled` removes the element from the focus order entirely (and `tabindex="0"` won't bring it back), which is often wrong for toolbar buttons or links. `aria-disabled="true"` keeps the element focusable so users can land on it and learn it's disabled.
#### DON'Ts
- **Don't use ARIA when native HTML exists**: Avoid `<div role="button">` or `<a role="button">` if `<button>` works.
- **Don't add redundant ARIA roles or properties**: Avoid `<ul role="list">`, `<nav role="navigation">`, or `<input required aria-required="true">`.
- **Caveat**: Safari removes list semantics from `<ul>`/`<ol>` outside `<nav>` when `list-style: none` or `display: flex`/`grid` is applied. In that case `role="list"` is required to restore them.
- **Don't assume custom elements have no ARIA**: Custom elements can attach ARIA via `ElementInternals`, which some automated test tools can't see — so the absence of `role`/`aria-*` attributes in markup doesn't prove the element has no semantics. Verify with the browser's accessibility-tree inspector.
## 3. Accessible Names and Descriptions
Every interactive element and some landmarks need an accessible name, and many benefit from an accessible description. Names are short and identify the element; descriptions add context.
### Actionable Guidelines
#### DOs
- **Prefer native naming mechanisms**: `<label>` for form controls, `<caption>` for `<table>`, `<legend>` for `<fieldset>`, `<figcaption>` for `<figure>`.
- **Explicitly associate `<label>` with its control via `for`/`id`**, even when nesting the input inside the label — explicit association improves assistive-tech support.
- **Prefer `aria-labelledby` over `aria-label` when a visible label exists**: avoids duplication, improves maintainability, and translates better.
- **Prefer to reuse the same accessible name for hyperlinks that share an `href`.**
- **Use visually hidden text to disambiguate controls** that look identical visually but do different things (e.g. multiple "Edit" buttons in a list).
#### DON'Ts
- **Don't put `aria-label`/`aria-labelledby` on elements that shouldn't be named** — e.g. plain `<div>`, `<span>`, or custom elements without a role. Custom elements may have an implicit role set via `ElementInternals`, so the absence of a `role` attribute isn't conclusive.
- **Don't reuse an accessible name across controls with different effects in the same view** (close buttons for two different open dialogs are fine because only one is reachable at a time; multiple “Edit” buttons for different content is not).
- **Don't reuse an accessible name across hyperlinks pointing to different `href`s.**
- **Don't pack descriptions, error messages, or instructions into the label.**
- **Don't repeat state already exposed via ARIA** (`aria-expanded`, `aria-checked`, `aria-selected`, `aria-pressed`) inside the accessible name — it creates redundancy and ambiguity.
- **Don't include the role name in the label**: `<nav aria-label="Primary navigation">` reads as "Primary navigation navigation."
- **Don't use `title` or `placeholder` as a naming mechanism.**
- **Don't include interactive elements in an `aria-describedby` target** unless their text content reads sensibly as a description on its own (e.g. if a link’s text is the same as how it’s labelled elsewhere, it can be included within a description).
### Code Example: Visually Hidden Utility
A `.visually-hidden` utility lets you provide text for screen readers without rendering it visually. It's commonly used for skip links, additional context on icon-only buttons, and supplementary labels.
```css
/* Hides content visually but keeps it in the accessibility tree.
:focus-within / :active opt elements out — useful for skip links and
any focusable content wrapped in this class. */
.visually-hidden:where(:not(:focus-within, :active)) {
position: absolute !important;
clip-path: inset(50%) !important;
overflow: hidden !important;
width: 1px !important;
height: 1px !important;
margin: -1px !important;
padding: 0 !important;
border: 0 !important;
white-space: nowrap !important;
}
```
When the hidden content is focusable (skip links, focus-receiving wrappers), the `:focus-within`/`:active` exception lets it become visible. Style the visible state per situation, e.g. a skip link to the main content typically wants fixed positioning at the top-left of the viewport so the rest of the page doesn't shift.
## 4. Document Metadata and Language
### Actionable Guidelines
#### DOs
- **Declare Visual Language**: Always set `<html lang="en">` (or appropriate code).
- **Unique Page Titles**: Front-load unique context in `<title>` (e.g., `Page Topic | Site Name`).
- **Inline Language Switches**: Use `lang="..."` for block quotes or text in different languages.
- **IFrame Titles**: Always provide a descriptive `title="..."` for `<iframe>` elements.
- **Update document title on Page Transitions in SPAs**: Shift focus to updated titles.
#### DON'Ts
- **Don't Disable iframe Scrolling**: Avoid `scrolling="no"` (deprecated) or `overflow: hidden` on iframes. Users who zoom in or enlarge text need to scroll to reach content that overflows.
### Code Examples
```html
<!-- Good: Distinct title and language declaration -->
<html lang="en">
<head>
<title>Analytics Reports | Guidance Platform</title>
</head>
<body>
<p>The motto is <span lang="la">"Carpe diem"</span>.</p>
<iframe title="Interactive Sales Chart" src="/chart"></iframe>
</body>
</html>
```
## 5. Keyboard and Focus Management
### Actionable Guidelines
#### DOs
- **Logical Tab Order**: Ensure tab order matches visual layouts (top-to-bottom).
- **Visible Focus Indicators**: Always style `:focus-visible` states explicitly. If disabling defaults, provide overrides with sufficient contrast.
- **Custom Trigger Keyboards**: Attach Enter/Space handlers for custom simulated interactive elements. When implementing a custom keyboard handler for button-like elements, `Enter` should be a `keydown` handler and `Space` should be a `keyup` handler (matching native `<button>` behavior where `Enter` repeats and `Space` triggers on release).
- **Use `tabindex` deliberately**: Anything focusable — by keyboard or programmatically — should have an implicit or explicit ARIA role, so don't make every element focusable. When focus is needed, choose `tabindex="0"` to add the element to the tab order or `tabindex="-1"` to make it programmatically focusable only (e.g., a skip-link target).
- **Manage Toggle States**: Utilize `aria-expanded` and `aria-pressed` to communicate toggle states for custom controls.
#### DON'Ts
- **Don't disable outlines without replacements**: Avoid `outline: none` without styling alternatives.
- **Don't use Positive Tabindex values**: Never use `tabindex="1"` or greater.
- **Don't hide interactive elements from screen readers**: Avoid `aria-hidden="true"` or `role="presentation"` on elements that can receive focus.
### Code Examples
```css
/* Good: High contrast focus border */
:where(a:any-link, button):focus-visible {
outline: 3px solid #ff0055;
outline-offset: 3px;
}
```
```html
<!-- Good: Skip to main content -->
<a href="#content" class="skip-link">Skip to main content</a>
<main id="content" tabindex="-1">...</main>
```
```javascript
// Good: Keyboard handlers for complex custom widgets (e.g., Tree items, tabs).
// NOTE: This pattern applies ONLY to non-standard UI where no native HTML tag exists.
// Always prioritize native <button> or <input> elements for standard interactions.
// Elements MUST have the appropriate ARIA role (e.g., role="treeitem" or role="tab").
customWidget.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
toggleWidgetState();
}
if (e.key === ' ') {
e.preventDefault(); // Prevent page scrolling on Spacebar keydown
}
});
customWidget.addEventListener('keyup', (e) => {
if (e.key === ' ') {
toggleWidgetState();
}
});
function toggleWidgetState() {
// E.g., Manage toggle/expanded states for custom controls
const isExpanded = customWidget.getAttribute('aria-expanded') === 'true';
customWidget.setAttribute('aria-expanded', !isExpanded);
}
```
## 6. Alternate Text and Media
### Actionable Guidelines
#### DOs
- **Informative Visual Descriptions**: Describe the purpose of the image (e.g., "Search", not "Magnifying glass").
- **Empty Alt properties for decorative visuals**: Use `alt=""` to remove decorative images from the accessibility tree so they aren't announced.
- **Synchronous Captions for videos**: Supply WebVTT captions for video tracks.
- **Transcripts for audio**: Provide text transcripts for purely audio podcasts.
- **Informative View Descriptions for inline SVGs**: Apply `role="img"` and a nested `<title>` tag for informative visuals.
- **Decorative SVGs removal**: Apply `aria-hidden="true"` to remove decorative SVGs from reading flows.
- **Long descriptions for complex images**: Use `<figure>`/`<figcaption>` or `aria-describedby` for charts and infographics.
- **Provide data tables as alternatives**: Consider providing semantic data tables as accessible alternatives for charts and other complex data visualizations.
#### DON'Ts
- **Don't use clichéd prefixes**: Avoid "Image of..." or "Picture of...".
- **Don't use underscores in filenames**: Use dashes if the filename might be announced as fallback.
### Code Examples
```html
<!-- Decorative -->
<img src="divider.png" alt="">
<!-- Inline Decorative SVG (remove from tab flow) -->
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>
<!-- Informative (Functional) -->
<a href="/search">
<img src="glass.png" alt="Search the platform">
</a>
<!-- Video with Captions tracks -->
<video controls>
<source src="intro.mp4" type="video/mp4">
<track src="caps.vtt" kind="captions" srclang="en" label="English">
</video>
<!-- Complex graph with figcaption -->
<figure>
<img src="chart.png" alt="Sales growth graph 2024.">
<figcaption>Sales grew 20% in Q3 due to new platform launch.</figcaption>
</figure>
<!-- Audio with expandable transcript details -->
<audio controls src="podcast.mp3" aria-details="podcast-transcript"></audio>
<details id="podcast-transcript">
<summary>View Transcript</summary>
<div class="transcript-content">
Welcome to the show...
</div>
</details>
```
### Content Visibility Decision Matrix
| Intent | Visual | Screen Reader | Focusable | Structural Pattern |
| :--- | :--- | :--- | :--- | :--- |
| **Visible to all** | Yes | Yes | Yes | Standard rendering |
| **Screen Reader only** | No | Yes | Yes (if interactive) | Visually hidden utility (e.g. `.visually-hidden`) |
| **Visual only** | Yes | No | No | `aria-hidden="true"` / `role="presentation"` |
| **Hidden for all** | No | No | No | `hidden` attribute / `display: none` |
**Heuristic Rule**: If an element can receive keyboard focus, it must not be hidden via `aria-hidden="true"`.
## 7. Forms and Input Controls
### Actionable Guidelines
#### DOs
- **Connect Labels Programmatically**: Use `<label for="id">` linked to `<input id="id">`.
- **Use Autocomplete**: Set valid standard `autocomplete` options (e.g., `"email"` or `"given-name"`) for user profiles.
- **Link hints to inputs via `aria-describedby`**: Associate help text with inputs, and place the hint above the input so autocomplete popovers don't cover it during editing.
- **Announce dynamic errors via live regions**: Use `aria-live` or shift focus to error lists.
- **Provide form validation constraints**: Use `required` (or `aria-required="true"` only when `required` isn't applicable) to signal mandatory inputs.
#### DON'Ts
- **Don't use placeholders as labels**: Placeholders are not persistent labels.
- **Don't trigger context shifts on focus changes**: Avoid auto-submitting forms or jumping pages on focus change events alone.
### Code Examples
```html
<!-- Good: Semantic forms with hints for passwords -->
<form>
<label for="pwd">Password:</label>
<span id="pwd-hint">Must contain at least 8 characters.</span>
<input id="pwd" type="password" aria-describedby="pwd-hint" autocomplete="current-password" required>
</form>
```
## 8. Live Regions
Live regions let assistive tech announce content updates that aren't tied to navigation or focus changes. They're easy to misuse — too many regions, or noisy ones, quickly become spam for screen-reader users.
### Live Region Urgency Table
| Urgency | Visual Analogue | `aria-live` Value | Behavioral Impact | Example |
| :--- | :--- | :--- | :--- | :--- |
| **Critical** | Modal / Alert | `assertive` (or `role="alert"`) | Interrupts immediately, clears speech queue | Session timeout, API failure |
| **Standard**| Toast / Banner | `polite` | Announces at next graceful break | Search results, "Saved" status |
| **Passive** | Silent text | `off` | Only if user navigates to it | Live character count |
**Heuristic Rule**: Use `assertive` only for critical, time-sensitive updates that require immediate attention or prevent safe continuation (e.g., data loss, session timeouts, or network drops).
### Actionable Guidelines
#### DOs
- **Centralize live regions for non-visible announcements**: A single `polite` region and a single `assertive` region per page (with whatever `aria-atomic` configuration you need) keeps announcements consistent and easier to maintain. Many frameworks ship their own announcer abstraction — use it.
- **Debounce frequently-changing regions**: If a region can update many times per second (e.g. a combobox's result count as the user types), debounce so users aren't spammed.
- **Delay slightly when other announcements may collide**: When the user is typing or focus is being managed, a small delay before announcing keeps live-region updates from overlapping other speech.
#### DON'Ts
- **Don't use live regions for interstitial states** like "Loading…" or "Updating…" unless they're meaningfully informative — they usually just create noise.
- **Don't add live-region updates to inert DOM**: When dialogs open or sections become `inert`, queued or debounced messages can end up unannounced — or announced from DOM the user can't reach. Coordinate live-region updates with dialog/inert state changes.
### Code Example
```html
<!-- Session Timeout Warning with controls -->
<div role="alert" class="timeout-warning">
Your session will expire in 2 minutes.
<button type="button" onclick="extendSession()">Extend Session</button>
</div>
```
## 9. Color, Contrast, and Typography
### Actionable Guidelines
#### DOs
- **Minimum contrast standards**: Maintain 4.5:1 for normal text and 3:1 for large text or icons.
- **Ensure non-text contrast standards**: Maintain a minimum contrast ratio of 3:1 for user interface component boundaries and states.
- This includes visual elements (borders, backgrounds, box-shadows, underlines) that form the boundary or indicate the presence of a UI component (e.g., input field borders).
- This also includes visual elements indicating active states within a component (e.g., checkbox checkmarks or switch thumbs).
- **Caveat**: Meeting 3:1 non-text contrast can challenge minimalistic designs. Soft gradients or subtle inset/outset shadows can soften visual boundaries while satisfying accessibility requirements.
- **Use multiple state indicators**: Do not denote success/errors ONLY with color. Use icons or text.
- **Relative font size units**: Use `rem` or `em` for font sizes instead of `px`.
- **Consistent or Start alignment**: Avoid `justify` alignment as it can be more difficult to read.
- **Avoid long lines of text**: Cap paragraph blocks to a maximum of 80 characters width.
- **Support user zoom preferences**: Allow users to resize text up to 200% without loss of content or functionality.
- **Support light and dark color schemes**: Honor `@media (prefers-color-scheme: dark)` and pair it with the `color-scheme` CSS property so form controls, scrollbars, and other UA-rendered surfaces match.
- **Use `prefers-contrast` only when warranted**: Reach for `@media (prefers-contrast: more)` when the design uses low-contrast accents (e.g., subtle borders, muted secondary text) that need to be reinforced; most sites that already meet baseline contrast won't need it.
#### DON'Ts
- **Don't use color alone to indicate the presence of a user interface component or its state**: Use iconography and/or shape to help differentiate.
- **Don't use Justified Text Alignment**: Avoid `text-align: justify`.
- **Don't use Ornate fonts**: Omit cursive typefaces for main reading content.
- **Don't rely on all-caps for emphasis**: Prefer bolding for visual emphasis, and use `<em>`/`<strong>` when the emphasis is semantic.
- **Limit emphasis overall**: Emphasis loses meaning when it's everywhere — apply it only where it changes how the content should be read.
### Code Examples
```css
/* Good: Relative sizing and line caps */
body {
line-height: 1.5;
text-align: start; /* Supports LTR and RTL */
}
article {
max-width: 80ch; /* Caps line length to ~80 characters for readability */
}
```
```html
<!-- Good: Denotes state without colors alone -->
<div class="error-msg">
<span aria-hidden="true">❌</span>
<span>The password entered was invalid.</span>
</div>
```
```css
/* Dark Mode support variables */
:root {
--bg-color: #ffffff;
--text-color: #212529;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #121212;
--text-color: #f8f9fa;
}
}
```
## 10. Motions and Preferences
### Actionable Guidelines
#### DOs
- **Support Reduced Motion media queries**: Support `@media (prefers-reduced-motion: reduce)` media queries.
- **Provide Pause mechanism**: Allow users to stop auto-running carousels banners or other persistent animations.
- **Default to static views**: Consider defaulting to static states and allowing users to opt-in to motion.
#### DON'Ts
- **Don't exceed flash limits (three per second)**: Never include rapid light-to-dark flashing. Such effects can cause seizures.
### Code Examples
```css
/* Good: Dampen spin states for reduced motion queries */
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
opacity: 0.5;
}
}
```
## 11. Modals and Native Dialogs
Modern browsers provide native solutions for creating modal dialogs which avoid the need for focus traps, managing the accessibility of outside content, ensuring the content is on top, and dimming the background content — all of which can be error prone and require heavy JavaScript event tracking to maintain.
### Actionable Guidelines
#### DOs
- **Use the Native `<dialog>` Element**: Invoke the dialog using the `.showModal()` method to open it in a modal state. When in a modal state, the browser sets outside content as inert (i.e. the outside content is hidden from the accessibility tree and cannot be interacted with nor be focused).
- **Use the `inert` Attribute for Custom Overlays**: When `<dialog>` cannot be used (e.g., some non-modal overlays, framework constraints, or layouts where `<dialog>`'s top-layer/positioning behavior conflicts with the design), apply `inert` to outside content to ensure it cannot be interacted with by keyboard, pointer, or assistive technology. This requires structuring elements in such a way that the custom overlay is not a descendant of the element with `inert` set on it.
#### DON'Ts
- **Don't implement focus traps for native modal dialogs**: When a `<dialog>` element is opened in a modal state, browsers set outside content as inert which is sufficient for ensuring only the dialog’s content can be focused.
### Code Examples
**HTML & JS: Native `<dialog>` with standard close events**
```html
<!-- Dialog opens natively with showModal() and locks focus -->
<button id="open-btn">Open Dialog</button>
<dialog id="accessible-modal" aria-labelledby="title-id">
<h2 id="title-id">Account Settings</h2>
<p>Update your details here.</p>
<button onclick="this.closest('dialog').close()">Close Dialog</button>
</dialog>
<script>
document.getElementById('open-btn').addEventListener('click', () => {
document.getElementById('accessible-modal').showModal();
});
</script>
```
## 12. Testing Validations
### Actionable Guidelines
#### DOs
- **Run Automated checks via axe-core or Lighthouse audits**: Catch missing alt texts or low contrasts (e.g., via Lighthouse in Chrome DevTools MCP).
- **Validate Sequential Navigations using keyboards alone**: Using only keyboard shortcuts, such as Tab/Shift+Tab, arrow keys, Enter, Space, and Esc, confirm every interactive element is reachable and operable, and that focus never gets stuck.
- **Test on Screen Readers with calibrated browsers**: Rely on standard bindings (e.g., JAWS with Chrome, NVDA with Firefox, Narrator with Edge, VoiceOver with Safari on macOS and iOS, TalkBack with Chrome for Android).
#### DON'Ts
- **Don't rely purely on scores**: A 100% score does not guarantee real usability.
guides/accessibility/accessible-error-announcement.md
# Accessible Error Announcement
## The Problem
Standard HTML5 validation provides visual feedback (via `:invalid` or `:user-invalid`), but it doesn't automatically synchronize with accessibility attributes like `aria-invalid`.
If you use standard `:invalid` styling, screen readers might announce "Invalid entry" the moment a user tabs into a required field that is currently empty. This creates a disruptive experience for users using assistive technologies, as the error is announced before interaction has occurred.
## The Solution
We want the *programmatic* state (`aria-invalid="true"`) to be applied **at the exact same moment** the *visual* state (`:user-invalid`) applies. Since `:user-invalid` relies on the browser's internal "user-interacted" flag, we can use JavaScript to check that this selector matches during standard interaction events.
See [MDN aria-invalid](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-invalid) for more details.
### Implementation Strategy
1. **Visual Layer**: Use CSS `:user-invalid` to show borders/icons.
2. **Accessibility Layer**: Use `aria-invalid` and `aria-errormessage` to communicate state to Assistive Technology (AT).
3. **Bridge Visual & Accessibility Layer**: Create a lightweight JavaScript utility that listens for `blur` and `input` events, checks if the element matches `:user-invalid`, and updates the ARIA attributes accordingly.
## Implementation Guide
### 1. HTML Structure
Link your input to its error message using `aria-errormessage` (or `aria-describedby` for broader support).
```html
<form>
<div class="field">
<label for="email">Email</label>
<input
type="email"
id="email"
required
aria-errormessage="email-error"
>
<span id="email-error" class="error-msg">
Please enter a valid email address.
</span>
</div>
</form>
```
### 2. CSS
Control the visibility of the error message using the native pseudo-class `:user-invalid`.
```css
.error-msg {
display: none;
color: #d93025;
}
/* Show error message when input is user-invalid */
input:user-invalid ~ .error-msg {
display: block;
}
/* Optional: Visual cues on the input itself */
input:user-invalid {
border-color: #d93025;
}
```
### 3. JavaScript
Since there is no "UserInvalidChanged" event, hook into standard form events to check the state.
```javascript
const updateAriaState = (event) => {
const input = event.target;
if (!input.matches?.('input, textarea, select')) return;
// Check if the browser currently considers this input "user-invalid"
const isUserInvalid = input.matches(':user-invalid');
if (isUserInvalid) {
input.setAttribute('aria-invalid', 'true');
} else {
input.removeAttribute('aria-invalid');
}
};
// Listen on the document to handle dynamically added fields.
// 'blur' and 'focus' do not bubble, so we must use the capture phase (true).
document.addEventListener('blur', updateAriaState, true);
document.addEventListener('focus', updateAriaState, true);
// Also update on input if we've already shown the error,
// so the error clears immediately when fixed.
document.addEventListener('input', (event) => {
const input = event.target;
if (!input.matches?.('input, textarea, select')) return;
const hasAriaInvalid = input.hasAttribute('aria-invalid');
const ariaInvalid = input.getAttribute('aria-invalid');
if (hasAriaInvalid && ariaInvalid === 'true') {
updateAriaState(event);
}
});
```
## Fallbacking & Browser Support
Baseline status for :user-valid and :user-invalid: Widely available. It's been Baseline since 2023-11-02.
Supported by: Chrome 119 (Oct 2023), Edge 119 (Nov 2023), Firefox 88 (Apr 2021), and Safari 16.5 (May 2023).
### Feature Detection
You can check for support in CSS and JavaScript.
**JavaScript Check:**
```javascript
if (!CSS.supports('selector(:user-invalid)')) {
// Fallback logic here
}
```
### CSS for Fallback
To ensure your fallback logic is visually indistinguishable from the native behavior, you must apply your error styles to both the pseudo-class and your fallback class.
```css
/* Apply error styles to both native selector and fallback class */
input:user-invalid,
input.user-invalid-fallback {
border-color: #d93025;
background-color: #fce8e6;
}
/* Show error message for both cases */
input:user-invalid ~ .error-msg,
input.user-invalid-fallback ~ .error-msg {
display: block;
}
```
### Fallback Logic
If `:user-invalid` is missing manually track the interaction state using a `WeakMap`.
```javascript
const UserInvalidFallback = (() => {
const dirtyState = new WeakMap();
const updateState = (input) => {
const isValid = input.checkValidity();
// Update both visual and ARIA state
input.classList.toggle('user-invalid-fallback', !isValid);
input.classList.toggle('user-valid-fallback', isValid);
if (!isValid) {
input.setAttribute('aria-invalid', 'true');
} else {
input.removeAttribute('aria-invalid');
}
};
const handleEvent = (event) => {
const input = event.target;
if (event.type === 'reset' && input.matches?.('form')) {
const controls = input.elements || [];
for (const control of controls) {
dirtyState.delete(control);
control.classList.remove('user-invalid-fallback');
control.classList.remove('user-valid-fallback');
control.removeAttribute('aria-invalid');
}
return;
}
if (!input.matches?.('input, textarea, select')) return;
if (event.type === 'input' || event.type === 'change') {
const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
state.hasInteracted = true;
dirtyState.set(input, state);
if (state.hasBlurred) {
updateState(input);
}
} else if (event.type === 'blur') {
const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
state.hasBlurred = true;
dirtyState.set(input, state);
if (state.hasInteracted) {
updateState(input);
}
}
};
const init = () => {
if (CSS.supports('selector(:user-invalid)')) return;
document.addEventListener('blur', handleEvent, true); // Capture phase required
document.addEventListener('input', handleEvent, true);
document.addEventListener('change', handleEvent, true);
document.addEventListener('reset', handleEvent, true); // Capture resets
};
return { init };
})();
// Initialize globally
UserInvalidFallback.init();
```
## Other Considerations
1. **`aria-live` vs. `aria-errormessage`**:
* `aria-errormessage` connects the input to the text, but screen readers might not announce it immediately upon appearance (only when focusing the input).
* If you need *immediate* announcement when the error appears (e.g., on blur), consider adding `role="alert"` or `aria-live="polite"` to the error message container, but test thoroughly to avoid "double announcement" when the user focuses the field to fix it.
2. **Internationalization**:
* Ensure the text content of your error message (`#email-error`) is translated. The logic remains the same.
guides/built-in-ai/language-detection.md
# Language Detection
The **Language Detector API** is a client-side web API designed to identify the language of a given text string. By performing detection locally in the browser, it enhances user privacy and reduces the need for heavy external libraries or costly server-side calls.
## Key Use Cases
- **Translation Prep:** Identifying the source language before sending text to a translator.
- **Safety & Filtering:** Loading specific models for tasks like toxicity detection.
- **Accessibility:** Labeling content with the correct `lang` attribute for screen readers.
- **UI Localization:** Adjusting application interfaces based on the user's input language.
## Hardware & System Requirements
- **OS:** Windows 10/11, macOS 13+, Linux, or Chromebook Plus.
- **Storage:** 22 GB free space (model is removed if space drops below 10 GB).
- **RAM/CPU:** 16 GB RAM and 4+ CPU cores.
- **VRAM:** 4 GB+ if using a GPU.
## Implementation Guide
### 1. Model Management & User Activation
Check model availability before attempting to instantiate the detector or trigger download.
**MANDATORY:** Instantiating the language detector or triggering a model download with `LanguageDetector.create()` **MUST** be initiated by a user gesture (such as a button click) to prevent a `NotAllowedError` when the model is in a `downloadable` or `downloading` state.
```javascript
// Check if the model is available or downloadable
const availability = await LanguageDetector.availability();
if (availability !== 'unavailable') {
button.addEventListener('click', async () => {
const detector = await LanguageDetector.create({
monitor(m) {
m.addEventListener('downloadprogress', (e) => {
console.log(`Downloaded ${e.loaded * 100}%`);
});
},
});
});
}
```
### 2. Running Detection
The API returns a ranked list of potential languages with a confidence score between `0.0` and `1.0`.
```javascript
const someUserText = 'Hallo und herzlich willkommen!';
const results = await detector.detect(someUserText);
for (const result of results) {
// result.detectedLanguage (e.g., 'de')
// result.confidence (e.g., 0.999)
console.log(result.detectedLanguage, result.confidence);
}
```
Avoid using the detector on very short phrases or single words, as accuracy drops significantly.
## Security and Environment
- **Iframes:** Cross-origin iframes require an explicit Permissions Policy to access the API.
```html
<iframe
src="https://cross-origin.example.com/"
allow="language-detector"
></iframe>
```
- **Web Workers:** The API is **not** currently available in Web Workers due to Permission Policy complexities.
- **Privacy:** No data is sent to Google or third parties during the detection process.
## Fallback Strategy
Language detector has limited availability.
Supported by: Chrome 138 (Jun 2025) and Edge 148 (May 2026).
Unsupported in: Firefox and Safari.
Before use, check if the `LanguageDetector` object is available in the global scope:
```javascript
if ('LanguageDetector' in self) {
// The Language Detector API is supported.
} else {
// Execute fallback strategy
}
```
If the `LanguageDetector` API is unsupported or availability checks return `'unavailable'`, you must gracefully fall back:
1. **Remote API Fallback**: Redirect the detection request to a server endpoint or a cloud API (such as the Vertex AI Gemini API) to identify the language.
2. **Graceful Degradation**: Disable language detection elements/buttons and inform the user that client-side detection is currently unsupported in this browser, preventing any unhandled exceptions or crashes.
guides/built-in-ai/language-model.md
# Language Model
The Prompt API allows developers to run natural language processing tasks directly in the browser using **Gemini Nano**. This built-in AI approach ensures user privacy, reduces server costs, and enables offline functionality.
## 1. Getting Started and Hardware Requirements
The Prompt API is currently available in Chrome as of version 148 (Desktop) for Windows, macOS, Linux, and Chromebook Plus.
### Hardware Prerequisites
- **Storage**: 22 GB free space (for the initial profile and model).
- **Memory/CPU**: 16 GB RAM and 4+ CPU cores.
- **GPU**: 4 GB VRAM or more (Required for audio input).
- **Network**: Required only for the initial model download.
### Initializing the API
Check model availability before triggering a download:
```javascript
const availability = await LanguageModel.availability();
// Do not call create() when unavailable — the model cannot run on this device.
if (availability !== 'unavailable') {
const session = await LanguageModel.create({
monitor(m) {
// Inform the user while the model downloads so the UI doesn't appear frozen.
m.addEventListener('downloadprogress', (e) => {
console.log(`Downloaded ${e.loaded * 100}%`);
});
},
});
}
```
## 2. Core Prompting Capabilities
Session examples in this section omit `session.destroy()` for brevity. Always call `session.destroy()` when a session is no longer needed to free device memory (see Section 5).
### Basic and Streamed Output
For short responses, use `prompt()`. For longer content, use `promptStreaming()` to provide a more responsive UI.
**MANDATORY**: Never assign model output to `innerHTML`. Model output is untrusted and can contain injected markup. Always use `textContent` or a sanitizer.
```javascript
const session = await LanguageModel.create();
// prompt() accumulates the full response before resolving — use for short, one-shot output.
const result = await session.prompt('Write a haiku about coding.');
// textContent, not innerHTML — model output is untrusted and must not be parsed as markup.
outputEl.textContent = result;
// promptStreaming() yields independent chunks that must be concatenated;
// use for longer content so each chunk can be rendered progressively.
const stream = session.promptStreaming('Write a long story about a robot.');
let completeResult = '';
for await (const chunk of stream) {
completeResult += chunk;
outputEl.append(chunk);
}
console.log('Full story:', completeResult);
```
### Multimodal Input
The Prompt API supports text, audio, and visual inputs (images, canvas, video frames).
```javascript
const session = await LanguageModel.create({
// Declaring expected input types lets the browser optimize model loading.
expectedInputs: [{ type: 'text' }, { type: 'image' }],
expectedOutputs: [{ type: 'text' }],
});
const response = await session.prompt([
{
role: 'user',
content: [
{ type: 'text', value: 'What is in this image?' },
{ type: 'image', value: document.querySelector('canvas') },
],
},
]);
```
## 3. Advanced Session Management
Sessions allow the model to maintain context across multiple interactions.
### Context and Quota
Each session has a maximum token limit. You can monitor usage via `session.contextUsage` and `session.contextWindow`. If the window overflows, the oldest messages (except the system prompt) are dropped.
### Cloning Sessions
Cloning is efficient for starting parallel conversations that share the same initial context (like a "system" personality) without re-initializing.
```javascript
const mainSession = await LanguageModel.create({
initialPrompts: [{ role: 'system', content: 'You speak like a pirate.' }],
});
const branchA = await mainSession.clone();
const branchB = await mainSession.clone();
// Destroy the base after cloning — the clones own their own context from here.
mainSession.destroy();
```
### Restoring Past Sessions
While a native "restore" feature is in development, you can recreate a session by feeding previous history into `initialPrompts`.
**Note**: `localStorage` is unencrypted and persistent. Stored conversation history may include user PII — consider the privacy implications before persisting chat history.
```javascript
// || '[]' ensures JSON.parse never receives null when the key doesn't exist yet.
const history = JSON.parse(localStorage.getItem('chat_history') || '[]');
const session = await LanguageModel.create({
initialPrompts: history, // Array of {role, content} objects
});
```
## 4. Structured Output with JSON Schema
To prevent the model from adding "chatter" (e.g., "Sure, here is your JSON:"), use a **JSON Schema** via the `responseConstraint` field. This ensures the output is valid JSON that can be parsed immediately.
### Example: Sentiment Classification
```javascript
// Pass the schema as a plain object — do not JSON.stringify() it first.
const schema = {
type: 'object',
properties: {
rating: { type: 'number', minimum: 1, maximum: 5 },
is_positive: { type: 'boolean' },
},
required: ['rating', 'is_positive'],
};
const result = await session.prompt(
"Rate the following feedback: 'The food was great!'",
{ responseConstraint: schema },
);
const data = JSON.parse(result);
console.log(data.rating); // 5
```
### Constraints and Prefixes
You can guide the model further by prefilling the assistant's response using `prefix: true`.
````javascript
const character = await session.prompt([
{ role: 'user', content: 'Create a character sheet' },
{ role: 'assistant', content: '```json\n', prefix: true },
]);
````
## 5. Best Practices and Safety
- **Resource Cleanup**: Always call `session.destroy()` when a conversation is finished to free up memory.
- **Output Safety**: Model output is untrusted. Always write results to `textContent`, not `innerHTML`, to prevent XSS injection from malicious model output.
- Use a sanitizer like the native Sanitizer API or DOMPurify if you need to allow limited HTML.
- **Aborting Tasks**: Use `AbortController` to allow users to stop long-running generations. Pass the `signal` to `prompt()` or `promptStreaming()`, not to `LanguageModel.create()`.
- **Security**: Use Permission Policies to control access in iframes: `<iframe src="..." allow="language-model"></iframe>`.
- **Design**: Review the [People + AI Guidebook](https://pair.withgoogle.com/guidebook/) to ensure responsible AI implementation.
By combining structured outputs with robust session management, developers can build complex, stateful AI applications that run entirely on the user's device.
### Dos and Don'ts
For the full detailed list of dos and don'ts, see https://developer.chrome.com/docs/ai/built-in-ai-dos-donts.md.txt. Below is the gist:
#### Prepare the model at a reasonable time
*Applies to: all APIs, for example, Summarizer, Translator, and Writer.*
**Do:** Initialize the session as soon as you've clearly established the user's intention to use the AI feature, for example, when a user navigates into a relevant AI tools surface, hovers over an AI workspace, or interacts with the feature's surrounding UI. Pre-warming the session allows the model to load into memory quietly in the background while the user is setting up their task, eliminating avoidable cold-start latency.
Try to be one step ahead by starting the next most likely AI task as soon as you start rendering the current result, for example, if the feature is designed for iterative use.
**Don't:** Unless necessary, don't wait for the user to click "Generate" to initialize the session. This leads to a cold start delay, because the model must first load into memory and prepare its execution pipeline.
> [!CAUTION]
> **Caution:** For the Prompt API, wait until you have the `initialPrompts` ready before calling `create()`, because these can only be set during session creation.
#### Set initial prompts during creation
*Applies to: Prompt API.*
**Do:** Provide system instructions during session initialization to improve the
speed of the first prompt.
**Don't:** Start with an empty session and send system instructions as part of
the first `prompt()` call. This increases latency because it forces the model to
process those instructions at the last moment.
#### Clone sessions for repetitive tasks
*Applies to: Prompt API.*
For the Prompt API, each session [tracks the context of the
conversation](https://developer.chrome.com/docs/ai/prompt-api?content_ref=each+session+keeps+track+of+the+context+of+the+conversation+previous+interactions+are+taken+into+account+for+future+interactions+until+the+session+s+context+window+is+full),
taking all previous interactions into account. Because a clone inherits
everything from its parent session, including initial prompts and all
interaction history up to the point of cloning, structure your usage to inherit
only what you need.
**Do:**
- Create a base session: To handle unrelated tasks efficiently, create a base session that contains only your system instructions and no previous conversational context.
- Clone the baseline: Use `clone()` on that base session for new tasks to save the overhead of re-parsing system instructions. This lets you create parallel conversations or reset a task to its baseline.
**Don't:**
- Don't reuse the same session for unrelated tasks, and avoid cloning any session that already contains unnecessary interaction history. Both patterns can cause unrelated previous context to interfere with your current task.
- Don't repeatedly call `create()` with identical system instructions. Use the cloning pattern instead to optimize performance.
#### Destroy unused sessions
*Applies to: All APIs.*
**Do:** Explicitly call [`destroy()`](https://developer.chrome.com/docs/ai/prompt-api#terminate_a_session) on
sessions that you no longer need, to free up memory when a feature
is no longer in use. If you use a cloning pattern, keep the base session and
destroy the clones you no longer need.
**Don't:** Keep multiple large sessions active. Each session consumes memory,
which creates unnecessary resource usage and might become a problem. Sessions
will be naturally cleaned up by the garbage collector, but calling `destroy()`
frees up memory more quickly.
#### Render streaming responses safely and efficiently
*Applies to: All APIs with streaming support (Prompt, Summarizer, Writer,
Rewriter, and Translator).*
**Do:** Treat all LLM output as untrusted content. Sanitize the full combined
output, not just chunks, because malicious code could be split across updates.
Before rendering, use the [Sanitizer
API](https://developer.mozilla.org/docs/Web/API/HTML_Sanitizer_API) where
supported. To avoid a decrease in performance, use a streaming Markdown parser
like [streaming-markdown](https://github.com/thetarnav/streaming-markdown).
**Don't:** Directly set `innerHTML` on every chunk update. This is slow,
especially with complex formatting like syntax highlighting, and vulnerable to
injection.
#### Optimize input for speed
*Applies to: All APIs.*
**Do:** Only pass to the model what's strictly needed. Strip everything that's
irrelevant to the task at hand. For large datasets, provide a short overview and
a small selection of relevant items.
**Don't:** Send raw unprocessed text, unnecessary metadata, HTML tags, or large
unfiltered lists to the APIs. Latency grows significantly with input size, which
can make the AI feature seem broken on many devices.
#### Use structured output for predictable results
*Applies to: Prompt API.*
**Do:** When you need the model to return data in a specific format, use
[structured
output](https://developer.chrome.com/docs/ai/structured-output-for-prompt-api?content_ref=he+prompt+api+lets+you+specify+a+json+output+format+of+the+model+s+response+by+passing+a+json+schema+to+the+languagemodel+prompt+and+languagemodel+promptstreaming+methods)
by providing a `responseConstraint` field to provide a JSON Schema. This ensures
the output is predictable and prevents you from needing complex post-processing
or manual parsing.
**Don't:** Rely on natural language instructions (like "output only JSON")
alone. Models might include conversational filler that breaks your parser.
#### Decouple generation from length constraints
*Applies to: Prompt API, as it's the only API that supports [structured output
schemas](https://developer.chrome.com/docs/ai/structured-output-for-prompt-api).*
**Do:** Let the model generate its response naturally, and then use client-side
logic to truncate the text to fit your UI.
**Don't:** Enforce strict character limits like `maxLength: 125` using
[structured output schemas](https://developer.chrome.com/docs/ai/structured-output-for-prompt-api). When a
model's response is longer than the limit you set, the model might switch to
high-density tokens like foreign languages or emoji to compress meaning,
resulting in nonsensical output.
#### Keep the user informed
*Applies to: All APIs.*
**Do:** Depending on the complexity and expected duration of the task, use animations, visual cues, and progress indicators to keep the user informed. The
optimal approach depends on your use case and the expected length of the API
output. Some ideas:
- Streaming for long content: For summaries or chat, streaming creates a per-token typewriter effect by default. This can feel natural and provide immediate feedback.
- Non-streaming for short tasks (or long async tasks): For short outputs, for example, alt-text, non-streaming can create a more polished UI. It also provides time to speculatively prepare the next AI task while the current one renders. This approach also works for longer asynchronous or background tasks. If the user is not blocked on the output to continue their journey, there is no urgent need to produce the output as it happens. Signal that the process is ongoing in the UI.
- Visual transitions for updates: When translating or rewriting text, use animations, for example, word-morphing.
**Don't:** Update the UI without visual cues.
#### Align with the user's mental model of time and work
*Applies to: All APIs.*
**Do:** Consider an artificial delay of one or two seconds if a response is
nearly instant. Paradoxically, users might find results more trustworthy when
they perceive a generation process that aligns with their perceived difficulty
of the task. Use animations to signal that an AI process has occurred.
**Don't:** Surprise users with instant UI replacements.
#### Allow users to quickly navigate and undo AI edits
*Applies to: All APIs.*
**Do:** Equip your UI with a stepper or navigation history that lets users
explore different results confidently, and let them quickly undo AI edits. This
ensures that different versions are still readily available.
**Don't:** Overwrite the user's previous draft, or an AI result they might have
liked without a way to go back, revert, or compare versions.
#### Empower user control and overrides
*Applies to: All APIs.*
**Do:** Make the user the final editor of all generated content. Provide intuitive overrides so the user maintains full ownership for the final output. The APIs may produce incorrect results.
**Don't:** Force an AI-generated result as the only option.
#### Cache results for repeated tasks
*Applies to: All APIs.*
**Do:** Implement a local result cache (for example, using `sessionStorage` or
`IndexedDB`) for repeated inputs or queries. Normalize the input by trimming
whitespace and lowercasing to increase cache hits. For heavy inputs, for
example, images, generate a hash to use as a cache key. Set a conservative
time to live (TTL) for your cache (or serve cached results while updating them
in the background). Let the user trigger a fresh inference if the result is
unsatisfying.
**Don't:** Re-run the same inference for a repeated search query or identical data input where variability isn't desirable, for example when a user navigates back and forth between search results.
This optimizes responsiveness and efficient use of local compute.
## 6. Fallback strategies
LanguageModel has limited availability.
Supported by: Chrome 148 (May 2026).
Unsupported in: Edge, Firefox, and Safari.
Before use, check if the LanguageModel object is available in the global scope:
```js
if ('LanguageModel' in self) {
// The Prompt API is supported.
} else {
// Execute fallback strategy
}
```
If the Prompt API is unsupported or availability checks return 'unavailable', you must gracefully fall back:
* Remote API Fallback: Redirect the detection request to a server endpoint or a cloud API (such as the Vertex AI Gemini API).
* Local API Fallback: Redirect the detection request to a local endpoint, for example, using Transformers.js.
guides/built-in-ai/prompt-api.md
# Language Model
The Prompt API allows developers to run natural language processing tasks directly in the browser using **Gemini Nano**. This built-in AI approach ensures user privacy, reduces server costs, and enables offline functionality.
## 1. Getting Started and Hardware Requirements
The Prompt API is currently available in Chrome as of version 148 (Desktop) for Windows, macOS, Linux, and Chromebook Plus.
### Hardware Prerequisites
- **Storage**: 22 GB free space (for the initial profile and model).
- **Memory/CPU**: 16 GB RAM and 4+ CPU cores.
- **GPU**: 4 GB VRAM or more (Required for audio input).
- **Network**: Required only for the initial model download.
### Initializing the API
Check model availability before triggering a download:
```javascript
const availability = await LanguageModel.availability();
// Do not call create() when unavailable — the model cannot run on this device.
if (availability !== 'unavailable') {
const session = await LanguageModel.create({
monitor(m) {
// Inform the user while the model downloads so the UI doesn't appear frozen.
m.addEventListener('downloadprogress', (e) => {
console.log(`Downloaded ${e.loaded * 100}%`);
});
},
});
}
```
## 2. Core Prompting Capabilities
Session examples in this section omit `session.destroy()` for brevity. Always call `session.destroy()` when a session is no longer needed to free device memory (see Section 5).
### Basic and Streamed Output
For short responses, use `prompt()`. For longer content, use `promptStreaming()` to provide a more responsive UI.
**MANDATORY**: Never assign model output to `innerHTML`. Model output is untrusted and can contain injected markup. Always use `textContent` or a sanitizer.
```javascript
const session = await LanguageModel.create();
// prompt() accumulates the full response before resolving — use for short, one-shot output.
const result = await session.prompt('Write a haiku about coding.');
// textContent, not innerHTML — model output is untrusted and must not be parsed as markup.
outputEl.textContent = result;
// promptStreaming() yields independent chunks that must be concatenated;
// use for longer content so each chunk can be rendered progressively.
const stream = session.promptStreaming('Write a long story about a robot.');
let completeResult = '';
for await (const chunk of stream) {
completeResult += chunk;
outputEl.append(chunk);
}
console.log('Full story:', completeResult);
```
### Multimodal Input
The Prompt API supports text, audio, and visual inputs (images, canvas, video frames).
```javascript
const session = await LanguageModel.create({
// Declaring expected input types lets the browser optimize model loading.
expectedInputs: [{ type: 'text' }, { type: 'image' }],
expectedOutputs: [{ type: 'text' }],
});
const response = await session.prompt([
{
role: 'user',
content: [
{ type: 'text', value: 'What is in this image?' },
{ type: 'image', value: document.querySelector('canvas') },
],
},
]);
```
## 3. Advanced Session Management
Sessions allow the model to maintain context across multiple interactions.
### Context and Quota
Each session has a maximum token limit. You can monitor usage via `session.contextUsage` and `session.contextWindow`. If the window overflows, the oldest messages (except the system prompt) are dropped.
### Cloning Sessions
Cloning is efficient for starting parallel conversations that share the same initial context (like a "system" personality) without re-initializing.
```javascript
const mainSession = await LanguageModel.create({
initialPrompts: [{ role: 'system', content: 'You speak like a pirate.' }],
});
const branchA = await mainSession.clone();
const branchB = await mainSession.clone();
// Destroy the base after cloning — the clones own their own context from here.
mainSession.destroy();
```
### Restoring Past Sessions
While a native "restore" feature is in development, you can recreate a session by feeding previous history into `initialPrompts`.
**Note**: `localStorage` is unencrypted and persistent. Stored conversation history may include user PII — consider the privacy implications before persisting chat history.
```javascript
// || '[]' ensures JSON.parse never receives null when the key doesn't exist yet.
const history = JSON.parse(localStorage.getItem('chat_history') || '[]');
const session = await LanguageModel.create({
initialPrompts: history, // Array of {role, content} objects
});
```
## 4. Structured Output with JSON Schema
To prevent the model from adding "chatter" (e.g., "Sure, here is your JSON:"), use a **JSON Schema** via the `responseConstraint` field. This ensures the output is valid JSON that can be parsed immediately.
### Example: Sentiment Classification
```javascript
// Pass the schema as a plain object — do not JSON.stringify() it first.
const schema = {
type: 'object',
properties: {
rating: { type: 'number', minimum: 1, maximum: 5 },
is_positive: { type: 'boolean' },
},
required: ['rating', 'is_positive'],
};
const result = await session.prompt(
"Rate the following feedback: 'The food was great!'",
{ responseConstraint: schema },
);
const data = JSON.parse(result);
console.log(data.rating); // 5
```
### Constraints and Prefixes
You can guide the model further by prefilling the assistant's response using `prefix: true`.
````javascript
const character = await session.prompt([
{ role: 'user', content: 'Create a character sheet' },
{ role: 'assistant', content: '```json\n', prefix: true },
]);
````
## 5. Best Practices and Safety
- **Resource Cleanup**: Always call `session.destroy()` when a conversation is finished to free up memory.
- **Output Safety**: Model output is untrusted. Always write results to `textContent`, not `innerHTML`, to prevent XSS injection from malicious model output.
- Use a sanitizer like the native Sanitizer API or DOMPurify if you need to allow limited HTML.
- **Aborting Tasks**: Use `AbortController` to allow users to stop long-running generations. Pass the `signal` to `prompt()` or `promptStreaming()`, not to `LanguageModel.create()`.
- **Security**: Use Permission Policies to control access in iframes: `<iframe src="..." allow="language-model"></iframe>`.
- **Design**: Review the [People + AI Guidebook](https://pair.withgoogle.com/guidebook/) to ensure responsible AI implementation.
By combining structured outputs with robust session management, developers can build complex, stateful AI applications that run entirely on the user's device.
### Dos and Don'ts
For the full detailed list of dos and don'ts, see https://developer.chrome.com/docs/ai/built-in-ai-dos-donts.md.txt. Below is the gist:
#### Prepare the model at a reasonable time
*Applies to: all APIs, for example, Summarizer, Translator, and Writer.*
**Do:** Initialize the session as soon as you've clearly established the user's intention to use the AI feature, for example, when a user navigates into a relevant AI tools surface, hovers over an AI workspace, or interacts with the feature's surrounding UI. Pre-warming the session allows the model to load into memory quietly in the background while the user is setting up their task, eliminating avoidable cold-start latency.
Try to be one step ahead by starting the next most likely AI task as soon as you start rendering the current result, for example, if the feature is designed for iterative use.
**Don't:** Unless necessary, don't wait for the user to click "Generate" to initialize the session. This leads to a cold start delay, because the model must first load into memory and prepare its execution pipeline.
> [!CAUTION]
> **Caution:** For the Prompt API, wait until you have the `initialPrompts` ready before calling `create()`, because these can only be set during session creation.
#### Set initial prompts during creation
*Applies to: Prompt API.*
**Do:** Provide system instructions during session initialization to improve the
speed of the first prompt.
**Don't:** Start with an empty session and send system instructions as part of
the first `prompt()` call. This increases latency because it forces the model to
process those instructions at the last moment.
#### Clone sessions for repetitive tasks
*Applies to: Prompt API.*
For the Prompt API, each session [tracks the context of the
conversation](https://developer.chrome.com/docs/ai/prompt-api?content_ref=each+session+keeps+track+of+the+context+of+the+conversation+previous+interactions+are+taken+into+account+for+future+interactions+until+the+session+s+context+window+is+full),
taking all previous interactions into account. Because a clone inherits
everything from its parent session, including initial prompts and all
interaction history up to the point of cloning, structure your usage to inherit
only what you need.
**Do:**
- Create a base session: To handle unrelated tasks efficiently, create a base session that contains only your system instructions and no previous conversational context.
- Clone the baseline: Use `clone()` on that base session for new tasks to save the overhead of re-parsing system instructions. This lets you create parallel conversations or reset a task to its baseline.
**Don't:**
- Don't reuse the same session for unrelated tasks, and avoid cloning any session that already contains unnecessary interaction history. Both patterns can cause unrelated previous context to interfere with your current task.
- Don't repeatedly call `create()` with identical system instructions. Use the cloning pattern instead to optimize performance.
#### Destroy unused sessions
*Applies to: All APIs.*
**Do:** Explicitly call [`destroy()`](https://developer.chrome.com/docs/ai/prompt-api#terminate_a_session) on
sessions that you no longer need, to free up memory when a feature
is no longer in use. If you use a cloning pattern, keep the base session and
destroy the clones you no longer need.
**Don't:** Keep multiple large sessions active. Each session consumes memory,
which creates unnecessary resource usage and might become a problem. Sessions
will be naturally cleaned up by the garbage collector, but calling `destroy()`
frees up memory more quickly.
#### Render streaming responses safely and efficiently
*Applies to: All APIs with streaming support (Prompt, Summarizer, Writer,
Rewriter, and Translator).*
**Do:** Treat all LLM output as untrusted content. Sanitize the full combined
output, not just chunks, because malicious code could be split across updates.
Before rendering, use the [Sanitizer
API](https://developer.mozilla.org/docs/Web/API/HTML_Sanitizer_API) where
supported. To avoid a decrease in performance, use a streaming Markdown parser
like [streaming-markdown](https://github.com/thetarnav/streaming-markdown).
**Don't:** Directly set `innerHTML` on every chunk update. This is slow,
especially with complex formatting like syntax highlighting, and vulnerable to
injection.
#### Optimize input for speed
*Applies to: All APIs.*
**Do:** Only pass to the model what's strictly needed. Strip everything that's
irrelevant to the task at hand. For large datasets, provide a short overview and
a small selection of relevant items.
**Don't:** Send raw unprocessed text, unnecessary metadata, HTML tags, or large
unfiltered lists to the APIs. Latency grows significantly with input size, which
can make the AI feature seem broken on many devices.
#### Use structured output for predictable results
*Applies to: Prompt API.*
**Do:** When you need the model to return data in a specific format, use
[structured
output](https://developer.chrome.com/docs/ai/structured-output-for-prompt-api?content_ref=he+prompt+api+lets+you+specify+a+json+output+format+of+the+model+s+response+by+passing+a+json+schema+to+the+languagemodel+prompt+and+languagemodel+promptstreaming+methods)
by providing a `responseConstraint` field to provide a JSON Schema. This ensures
the output is predictable and prevents you from needing complex post-processing
or manual parsing.
**Don't:** Rely on natural language instructions (like "output only JSON")
alone. Models might include conversational filler that breaks your parser.
#### Decouple generation from length constraints
*Applies to: Prompt API, as it's the only API that supports [structured output
schemas](https://developer.chrome.com/docs/ai/structured-output-for-prompt-api).*
**Do:** Let the model generate its response naturally, and then use client-side
logic to truncate the text to fit your UI.
**Don't:** Enforce strict character limits like `maxLength: 125` using
[structured output schemas](https://developer.chrome.com/docs/ai/structured-output-for-prompt-api). When a
model's response is longer than the limit you set, the model might switch to
high-density tokens like foreign languages or emoji to compress meaning,
resulting in nonsensical output.
#### Keep the user informed
*Applies to: All APIs.*
**Do:** Depending on the complexity and expected duration of the task, use animations, visual cues, and progress indicators to keep the user informed. The
optimal approach depends on your use case and the expected length of the API
output. Some ideas:
- Streaming for long content: For summaries or chat, streaming creates a per-token typewriter effect by default. This can feel natural and provide immediate feedback.
- Non-streaming for short tasks (or long async tasks): For short outputs, for example, alt-text, non-streaming can create a more polished UI. It also provides time to speculatively prepare the next AI task while the current one renders. This approach also works for longer asynchronous or background tasks. If the user is not blocked on the output to continue their journey, there is no urgent need to produce the output as it happens. Signal that the process is ongoing in the UI.
- Visual transitions for updates: When translating or rewriting text, use animations, for example, word-morphing.
**Don't:** Update the UI without visual cues.
#### Align with the user's mental model of time and work
*Applies to: All APIs.*
**Do:** Consider an artificial delay of one or two seconds if a response is
nearly instant. Paradoxically, users might find results more trustworthy when
they perceive a generation process that aligns with their perceived difficulty
of the task. Use animations to signal that an AI process has occurred.
**Don't:** Surprise users with instant UI replacements.
#### Allow users to quickly navigate and undo AI edits
*Applies to: All APIs.*
**Do:** Equip your UI with a stepper or navigation history that lets users
explore different results confidently, and let them quickly undo AI edits. This
ensures that different versions are still readily available.
**Don't:** Overwrite the user's previous draft, or an AI result they might have
liked without a way to go back, revert, or compare versions.
#### Empower user control and overrides
*Applies to: All APIs.*
**Do:** Make the user the final editor of all generated content. Provide intuitive overrides so the user maintains full ownership for the final output. The APIs may produce incorrect results.
**Don't:** Force an AI-generated result as the only option.
#### Cache results for repeated tasks
*Applies to: All APIs.*
**Do:** Implement a local result cache (for example, using `sessionStorage` or
`IndexedDB`) for repeated inputs or queries. Normalize the input by trimming
whitespace and lowercasing to increase cache hits. For heavy inputs, for
example, images, generate a hash to use as a cache key. Set a conservative
time to live (TTL) for your cache (or serve cached results while updating them
in the background). Let the user trigger a fresh inference if the result is
unsatisfying.
**Don't:** Re-run the same inference for a repeated search query or identical data input where variability isn't desirable, for example when a user navigates back and forth between search results.
This optimizes responsiveness and efficient use of local compute.
## 6. Fallback strategies
LanguageModel has limited availability.
Supported by: Chrome 148 (May 2026).
Unsupported in: Edge, Firefox, and Safari.
Before use, check if the LanguageModel object is available in the global scope:
```js
if ('LanguageModel' in self) {
// The Prompt API is supported.
} else {
// Execute fallback strategy
}
```
If the Prompt API is unsupported or availability checks return 'unavailable', you must gracefully fall back:
* Remote API Fallback: Redirect the detection request to a server endpoint or a cloud API (such as the Vertex AI Gemini API).
* Local API Fallback: Redirect the detection request to a local endpoint, for example, using Transformers.js.
guides/built-in-ai/summarizer.md
# Summarizer
The **Summarizer API** allows web developers to offer local, AI-powered text distillation directly within the browser using **Gemini Nano in Chrome or Phi in Edge**. This API supports various formats, including key points, headlines, and TL;DRs, while operating entirely on-device to ensure user privacy.
---
## Getting Started
The Summarizer API is available starting in **Chrome and Edge 138**. It requires a one-time model download of Gemini Nano or Phi (respectively).
### Hardware & Software Requirements
- **OS**: Windows 10/11, macOS 13+, Linux, or ChromeOS (Chromebook Plus).
- **Storage**: 22GB free space for the profile volume.
- **RAM/CPU**: 16GB+ RAM and 4+ CPU cores.
- **VRAM**: 4GB+ (if using GPU).
### Model Download and Availability
Check if the model is ready, needs downloading, or is unavailable.
**Mandatory Options Passing:** You must pass the identical configuration options object to both `Summarizer.availability(options)` and `Summarizer.create(options)`. Do NOT use the deprecated `window.ai.summarizer` API surface.
**Mandatory Progress Monitoring:** You MUST implement a monitor for model download progress by providing a `monitor(m)` callback to `Summarizer.create()` and adding a listener for the `downloadprogress` event.
**User Gesture Requirement:** When `availability` is `'downloadable'` or `'downloading'`, triggering the actual download via `Summarizer.create()` requires a user gesture (such as a user click). You must place the creation call inside an event listener rather than calling it unconditionally on page load to prevent `NotAllowedError`.
```javascript
const options = {
type: 'key-points',
format: 'plain-text',
length: 'medium'
};
const availability = await Summarizer.availability(options);
if (availability === 'available') {
const summarizer = await Summarizer.create(options);
// Ready to use immediately
} else if (availability === 'downloadable') {
// A user gesture is strictly required to start the download
document.getElementById('start-download-btn').addEventListener('click', async () => {
const summarizer = await Summarizer.create({
...options,
monitor(m) {
m.addEventListener('downloadprogress', (e) => {
console.log(`Downloaded ${Math.round((e.loaded / e.total) * 100)}%`);
});
},
});
});
}
```
## API Functions & Configuration
When creating a summarizer via `Summarizer.create(options)`, you can customize
the output:
| Parameter | Options | Description |
| :----------- | :----------------------------------------- | :-------------------------------------- |
| `type` | `key-points`, `tldr`, `teaser`, `headline` | Defines the summary strategy. |
| `format` | `markdown`, `plain-text` | Output syntax style. |
| `length` | `short`, `medium`, `long` | Target length (e.g., 1 vs 5 sentences). |
| `preference` | `auto`, `speed`, `capability` | Balances latency vs. quality. |
### Example Configuration
```javascript
const options = {
sharedContext: 'This is a scientific article',
type: 'key-points',
format: 'markdown',
length: 'medium',
};
if (navigator.userActivation.isActive) {
const summarizer = await Summarizer.create(options);
}
```
### Language Support
You can specify expected languages to ensure the browser can handle the specific
summary request.
```javascript
const summarizer = await Summarizer.create({
type: 'key-points',
expectedInputLanguages: ['en', 'ja'],
outputLanguage: 'es',
});
```
## Summarization Methods
### 1. Batch Summarization
Processes the entire text at once and returns the result.
```javascript
const longText = document.querySelector('article').innerText;
const summary = await summarizer.summarize(longText, {
context: 'This article is intended for a tech-savvy audience.',
});
console.log(summary);
```
### 2. Stream Summarization
Returns results in real-time as the model generates them, providing a more
responsive UI.
```javascript
const stream = summarizer.summarizeStreaming(longText);
for await (const chunk of stream) {
console.log(chunk);
}
```
## Security and Permissions
- **Data Privacy**: No data is sent to Google; processing happens on the local
device.
- **Cross-Origin**: Access can be granted to iframes using the Permission
Policy.
```html
<iframe src="https://example.com/" allow="summarizer"></iframe>
```
- **Web Workers**: Currently not supported.
## Fallback Strategy
Summarizer has limited availability.
Supported by: Chrome 138 (Jun 2025) and Edge 138 (Jun 2025).
Unsupported in: Firefox and Safari.
Before initializing or querying availability, check if the browser supports the `Summarizer` API:
```javascript
if ('Summarizer' in self) {
// The Summarizer API is supported.
} else {
// Execute fallback strategy
}
```
If the `Summarizer` API is unsupported or availability checks return `'unavailable'`, you must gracefully fall back.
Recommended options:
1. **Remote API Fallback**: Direct the distillation request to a server endpoint or remote API (such as the Vertex AI Gemini API) so that users still get summaries.
2. **Graceful Degradation**: Visually disable the summarization controls in the UI, or hide the button while displaying a friendly message (e.g., `"Local summarization is currently unsupported in this browser"`). Do not allow interaction to trigger generic unhandled runtime exceptions.
3. **Polyfill Fallback**: You can use community-maintained polyfills like `built-in-ai-task-apis-polyfills` or `prompt-api-polyfill` to emulate the API surface using remote services with models in the cloud or on-device inference with local models.
> **Privacy and Cost Implications:** These polyfills possibly proxy requests to remote servers (such as Gemini API over the cloud). This completely nullifies the on-device privacy guarantees of the native Built-in AI APIs and will incur server-side API usage costs.
guides/built-in-ai/translator.md
# Translator
The **Translator API** allows developers to perform client-side text translation using built-in AI models in Chrome and Edge. This approach eliminates the need for cloud-based translation services for ephemeral content, reducing costs and improving privacy by keeping data on the user's device.
## Prerequisites & Requirements
### API Surface & Global Scope
- **MANDATORY:** Access the Translator API exclusively via the global `Translator` interface (`window.Translator` / `self.Translator`).
- **DO NOT** use or check the deprecated `window.ai.translator` namespace.
### Browser Support
- **Chrome:** Version 138+ (Desktop only).
- **Edge:** Version 148+ (Desktop only).
- **Not Supported:** Mobile (Android/iOS), Firefox, Safari.
### Hardware Requirements
To run Gemini Nano and associated models, the system needs:
- **Operating System:** Windows 10/11, macOS 13+, Linux, or ChromeOS (Chromebook
Plus).
- **Storage:** At least **22 GB** free on the profile volume.
- **Memory/CPU:** 16 GB+ RAM and 4+ CPU cores.
- **GPU:** 4 GB+ VRAM (Mandatory for Prompt API with audio).
- **Network:** Required only for the initial download of language packs/models.
## Implementation & Code Samples
### 1. Checking Availability & Model Management
**Mandatory Options Passing:** You must pass the identical configuration options object containing `sourceLanguage` and `targetLanguage` to both `Translator.availability(options)` and `Translator.create(options)`.
**Recommended Progress Monitoring:** You should implement a monitor for model download progress by providing a `monitor(m)` callback to `Translator.create()` and adding a listener for the `downloadprogress` event, so the user can see model download progress.
**User Gesture Requirement:** When calling `availability(options)` returns `'downloadable'` or `'downloading'`, calling `Translator.create()` triggers the download of the language pack and **strictly requires a user gesture** (such as a button click) to prevent a `NotAllowedError`.
`Translator.availability(options)` returns one of four string statuses:
- `'available'`: The language pair model is already downloaded on the device and ready for immediate translation.
- `'downloadable'`: The language pair is supported, but the model needs to be downloaded. A user gesture is required to initiate `Translator.create()`.
- `'downloading'`: The language pack is currently in the process of downloading. Calling `Translator.create()` with a user gesture attaches to the download.
- `'unavailable'`: The language pair or device is not supported. Execute your fallback strategy.
```javascript
// Language pair options passed to both availability() and create()
const options = {
sourceLanguage: 'es', // Example BCP 47 language code
targetLanguage: 'fr', // Example BCP 47 language code
};
// 1. Check availability for the language pair
const availability = await Translator.availability(options);
if (availability === 'available') {
// Model is ready immediately on device
const translator = await Translator.create(options);
} else if (availability === 'downloadable' || availability === 'downloading') {
// User gesture is strictly required before create() triggers or attaches to download
document.getElementById('start-translation-btn').addEventListener('click', async () => {
const translator = await Translator.create({
...options,
monitor(m) {
m.addEventListener('downloadprogress', (e) => {
console.log(`Downloaded ${Math.round(e.loaded * 100)}%`);
});
},
});
});
} else if (availability === 'unavailable') {
// Language pair or hardware unsupported; execute fallback
console.warn('Translation model is unavailable on this device.');
}
```
### 2. Executing Translations
The API supports both static and streaming responses. Always include download progress monitoring when instantiating the translator.
**Standard Translation:**
```javascript
// Default to including a progress monitor when creating translator
const translator = await Translator.create({
sourceLanguage: 'en',
targetLanguage: 'fr',
monitor(m) {
m.addEventListener('downloadprogress', (e) => {
console.log(`Downloaded ${Math.round(e.loaded * 100)}%`);
});
},
});
const result = await translator.translate(
'Where is the next bus stop, please?',
);
console.log(result);
// Output: "Où est le prochain arrêt de bus, s'il vous plaît ?"
```
**Streaming Translation (for long text):**
```javascript
const stream = translator.translateStreaming(longText);
for await (const chunk of stream) {
console.log(chunk);
}
```
## Supported Languages
The API supports a wide range of BCP 47 language codes: Here are the languages supported by Chrome's implementation of the Translator API:
- **ar**: Arabic
- **bg**: Bulgarian
- **bn**: Bengali
- **cs**: Czech
- **da**: Danish
- **de**: German
- **el**: Greek
- **en**: English
- **es**: Spanish
- **fi**: Finnish
- **fr**: French
- **hi**: Hindi
- **hr**: Croatian
- **hu**: Hungarian
- **id**: Indonesian
- **it**: Italian
- **he**: Hebrew
- **ja**: Japanese
- **kn**: Kannada
- **ko**: Korean
- **lt**: Lithuanian
- **mr**: Marathi
- **nl**: Dutch
- **no**: Norwegian
- **pl**: Polish
- **pt**: Portuguese
- **ro**: Romanian
- **ru**: Russian
- **sk**: Slovak
- **sl**: Slovenian
- **sv**: Swedish
- **ta**: Tamil
- **te**: Telugu
- **th**: Thai
- **tr**: Turkish
- **uk**: Ukrainian
- **vi**: Vietnamese
- **zh**: Chinese
- **zh-Hant**: Chinese (Traditional)
## Security & Performance
- **Permissions Policy:** Cross-origin iframes require explicit permission.
```html
<iframe src="https://example.com/" allow="translator"></iframe>
```
- **Web Workers:** Currently **not supported** due to Permission Policy
complexities.
- **Privacy:** No data is sent to Google servers during the translation process
once the model is downloaded.
## Fallback Strategy
Translator has limited availability.
Supported by: Chrome 138 (Jun 2025) and Edge 148 (May 2026).
Unsupported in: Firefox and Safari.
Before use, check if the `Translator` object is available in the global scope:
```javascript
if ('Translator' in self) {
// The Translator API is supported.
} else {
// Execute fallback strategy (do not fall back to window.ai.translator).
}
```
If the `Translator` API is unsupported or availability checks return `'unavailable'`, you must gracefully fall back.
Recommended options:
1. **Remote API Fallback**: Redirect the translation request to a server endpoint or cloud remote API (such as the Vertex AI Gemini API) to deliver translation functionality.
2. **Graceful Degradation**: Visually disable translation control elements or buttons while showing an end-user friendly note (e.g., `"Client-side translation is currently unsupported in this browser"`). Do not allow unhandled exceptions.
3. **Polyfill Fallback**: You can use community-maintained polyfills like `built-in-ai-task-apis-polyfills` or `prompt-api-polyfill` to emulate the API surface using remote services.
> **Privacy and Cost Implications:** These polyfills possibly proxy requests to remote servers (such as Gemini API over the cloud), though local processing is an option, too. Remote processing completely nullifies the on-device privacy guarantees of the native Built-in AI APIs and will often incur server-side API usage costs.
guides/css/animate-to-intrinsic-sizes.md
# Animate to Intrinsic Sizes
Animating elements to dynamic sizes like `block-size: auto` or `inline-size: max-content` has historically required JavaScript or fragile "max-height" hacks. The `interpolate-size` property and `calc-size()` function allow the browser to natively interpolate between fixed lengths and intrinsic sizing keywords.
## Implementation steps
1. **Opt-in to keyword interpolation**: Apply `interpolate-size: allow-keywords` to a parent element (typically `:root`) to enable transitions for properties using intrinsic keywords.
2. **Define the transition**: Set a `transition` for the sizing property (e.g., `block-size`, `inline-size`) on the target element.
3. **Use intrinsic keywords**: Change the sizing property to a supported intrinsic keyword—`auto`, `min-content`, `max-content`, `fit-content`, or (for flex-basis) `content`—during an interaction (e.g., `:hover` or a state class).
4. **Perform calculations (Optional)**: Use `calc-size()` if you need to perform math on an intrinsic size (e.g., `auto + 2rem`). `calc-size()` also supports the `any` keyword for basis-agnostic calculations.
## Example: Generic Expansion Pattern
You can apply this pattern to any container (like a "Show More" section or a navigation menu) to transition between a restricted height and the element's natural size.
```css
/* Opt-in globally for all children */
:root {
/* MANDATORY: Transitions to intrinsic keywords are disabled by default for compatibility */
interpolate-size: allow-keywords;
}
.expandable-container {
/* 1. Define a fixed initial size (or 0) and hide overflow */
block-size: 100px;
overflow: hidden;
/* 2. Transition the sizing property */
transition: block-size 0.4s ease-out;
}
.expandable-container.is-expanded {
/* 3. Smoothly animate to the intrinsic natural height */
block-size: auto;
}
```
## Example: Calculated Intrinsic Inline-Size
```css
.badge {
inline-size: 40px;
overflow: hidden;
white-space: nowrap;
transition: inline-size 0.3s ease;
}
.badge:hover {
/* calc-size(basis, calculation) */
/* 'size' refers to the evaluated basis (max-content in this case) */
inline-size: calc-size(max-content, size + 20px);
}
```
## Example: Transition from Intrinsic to Fixed
You can also animate in the opposite direction—starting from a natural size and collapsing to a specific length. This is useful for "dismissible" components.
```css
.collapsible-alert {
/* 1. Start with the natural content height */
block-size: auto;
overflow: hidden;
transition: block-size 0.5s ease-in-out, opacity 0.5s ease;
}
.collapsible-alert.is-dismissed {
/* 2. Smoothly collapse to zero */
block-size: 0;
opacity: 0;
pointer-events: none;
}
/* MANDATORY Copy-Paste Safety: Disable sizing animations for sensitive users */
@media (prefers-reduced-motion: reduce) {
.expandable-container,
.badge,
.collapsible-alert {
transition: none !important;
}
}
```
```javascript
// MANDATORY Accessibility Synchronization: Ensure elements collapsed to zero dimensions are removed from the assistive technology tree, and sync aria-expanded states on triggers.
const alertElement = document.querySelector('.collapsible-alert');
alertElement.addEventListener('transitionend', (e) => {
if (e.propertyName === 'block-size' && alertElement.classList.contains('is-dismissed')) {
alertElement.hidden = true;
}
});
// Example trigger syncer
const triggerBtn = document.querySelector('.accordion-trigger');
triggerBtn?.addEventListener('click', () => {
const isExpanded = triggerBtn.getAttribute('aria-expanded') === 'true';
triggerBtn.setAttribute('aria-expanded', !isExpanded);
});
```
## Key constraints
* **Keyword-to-Keyword Restriction**: You cannot animate between two different keywords directly (e.g., from `min-content` to `max-content`). One end of the transition must be a fixed length or percentage (e.g., `0` to `auto`).
* **Calc-size Syntax**: Inside `calc-size()`, you cannot mix different intrinsic keywords in the same expression. The first argument (the basis) defines what `size` represents.
* **Opt-in Requirement**: Transitions to intrinsic keywords are disabled by default (`numeric-only`) to maintain backward compatibility. You must apply `interpolate-size: allow-keywords` to the element or an ancestor. `calc-size()` acts as a per-property override, automatically enabling interpolation whenever it is used.
## Fallback strategies
interpolate-size has limited availability.
Supported by: Chrome 129 (Sep 2024) and Edge 129 (Sep 2024).
Unsupported in: Firefox and Safari.
calc-size() has limited availability.
Supported by: Chrome 129 (Sep 2024) and Edge 129 (Sep 2024).
Unsupported in: Firefox and Safari.
`interpolate-size` and `calc-size()` are progressive enhancements. Browsers that do not support them will perform an instant jump to the target size.
* **Graceful Degradation**: For simple `block-size: auto` transitions, standard browsers will simply toggle the size instantly, which is functional but less polished.
* **Manual keyword fallbacks**: When using `calc-size()`, always provide a standard keyword fallback for older browsers, as they will discard the entire `calc-size()` declaration.
```css
.card {
block-size: auto; /* Fallback for older browsers */
block-size: calc-size(auto, size); /* Modern browsers use this */
transition: block-size 0.3s ease;
}
```
guides/css/calculate-with-intrinsic-sizes.md
# Calculate With Intrinsic Sizes
`calc-size()` is a CSS function for performing mathematical operations on intrinsic sizing keywords like `auto`, `min-content`, and `fit-content`. **MANDATORY**: Use `calc-size()` only when you need to modify an intrinsic size with a calculation or constraint; for simple keyword-based animations (e.g., `0` to `auto`), you must use `interpolate-size: allow-keywords`.
## Implementation Steps
1. **Identify the Intrinsic Basis**: Determine which intrinsic keyword (`auto`, `min-content`, etc.) should form the base of your calculation.
2. **Define Constraints**: Use CSS math functions like `clamp()`, `min()`, or `max()` within the second argument to enforce design constraints on the intrinsic size.
3. **MANDATORY: Provide a Fallback**: Always declare a standard sizing keyword or length immediately before the property using `calc-size()` to ensure the layout remains functional in unsupported browsers.
4. **Apply Logical Properties**: Default to using logical properties like `inline-size` or `block-size` to ensure the calculations respect the document's writing mode.
5. **Optional: Progressive Enhancement**: Wrap complex layout logic or animations in a `@supports (inline-size: calc-size(auto, size + 0px))` block to deliver advanced features only to capable browsers.
## Basic Syntax
```css
/* calc-size(<calc-size-basis>, <calc-sum>) — mathematical operations on intrinsic sizing keywords */
.element {
/* MANDATORY: Always provide a fallback for browsers that do not support calc-size() */
inline-size: min-content;
/* DO: Use calc-size to modify an intrinsic basis with a calculation or function */
inline-size: calc-size(min-content, size + 2rem);
}
```
### Valid Basis Arguments (`<calc-size-basis>`)
The first argument defines the "base" size for the calculation.
**Standard Keywords:**
- `auto`: The default sizing for the element.
- `min-content`: The smallest size the element can take without overflowing.
- `max-content`: The size the element takes to fit all content on one line.
- `fit-content`: Equivalent to `clamp(min-content, auto, max-content)`.
- `content`: Only valid when `calc-size()` is used within the `flex-basis` property.
**Special Arguments:**
- `any`: A generic basis used when the specific intrinsic type is unknown or when nesting calculations.
- Nested `calc-size()`: Allows for multi-step or conditional calculations.
- `<calc-sum>`: A specific length, percentage, or mathematical expression (e.g., `100px` or `20%`). When a fixed value is used as the basis, the **`size` keyword is still available** (but only within the second argument) and represents the resolved value of that basis.
**MANDATORY**: The `size` keyword is **not valid** within the first argument (`<calc-size-basis>`) itself. It is a local variable that only exists to refer back to the basis from within the second argument (`<calc-sum>`).
### Valid Calculation Arguments (`<calc-sum>`)
The second argument is the mathematical expression.
- It typically uses the `size` keyword to refer to the value of the basis.
- While the `size` keyword is technically optional, omitting it means the calculation will resolve to a fixed value, ignoring the basis entirely.
- It can include standard math operators (`+`, `-`, `*`, `/`).
- It can include CSS math functions like `clamp()`, `min()`, `max()`, and `round()`.
- **MANDATORY**: `calc-size()` only allows a **single** intrinsic size value (the basis) in each calculation. You cannot mix intrinsic sizing keywords in the same `calc-size()` call.
## Use Cases
### Animating to and from Intrinsic Sizes
By default, browsers cannot interpolate between a length (e.g., `0px`) and an intrinsic keyword (e.g., `auto`). Wrapping the keyword in `calc-size()` makes it an interpolatable value.
#### Choosing the Right Tool for Animations
- **MANDATORY: Use `interpolate-size: allow-keywords`**: For simple animations to or from intrinsic sizes (e.g., `height: 0` to `height: auto`) without any mathematical modifications. This is the required approach for simple keyword interpolation and should ideally be applied globally via `:root`.
```css
:root {
/* Best practice: Enable keyword interpolation globally */
interpolate-size: allow-keywords;
}
.item {
height: 0;
transition: height 0.3s ease;
}
.item.open {
/* Simple interpolation from 0 to auto now works without calc-size() */
height: auto;
}
```
- **Use `calc-size()`**: ONLY when you need to perform mathematical calculations on an intrinsic size during a transition (e.g., adding padding or clamping the size).
```css
.accordion-content {
display: block;
overflow: hidden;
/* MANDATORY: Fallback value for closed state */
block-size: 0;
transition: block-size 0.3s ease-out;
}
.accordion-content.open {
/* MANDATORY: Fallback value for open state */
block-size: auto;
/*
DO: Use calc-size(auto, ...) to enable animation from 0 to the element's
intrinsic size while doing a calculation (in this case, adding a space of 2rem).
*/
block-size: calc-size(auto, size + 2rem);
}
```
**MANDATORY**: Interpolation between two intrinsic sizing keywords is not possible directly. One end of the transition must be a length or a percentage.
#### Respecting User Motion Preferences
Animations that change the size of large layout areas can be particularly disruptive for users with vestibular disorders. **MANDATORY**: Always respect user motion preferences by using the `prefers-reduced-motion` media query to simplify or minimize non-essential animations. Common strategies include disabling motion entirely, reducing duration, or replacing layout shifts with subtle opacity transitions.
```css
.accordion-content {
opacity: 0;
transition: block-size 0.3s ease, opacity 0.3s ease;
}
.accordion-content.open {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.accordion-content {
/*
EXAMPLE: Replacing disruptive layout animations with a subtle fade-in.
Setting the block-size instantly and transitioning opacity
provides a clear state change without large-scale motion.
*/
transition: opacity 1.5s ease;
}
.accordion-content.open {
/* Jump the size instantly */
block-size: auto;
}
}
```
### Applying Constraints to Intrinsic Sizes
You can use `calc-size()` with any CSS math function—such as `min()`, `max()`, `clamp()`, or `round()`—to ensure an element's intrinsic size remains within design boundaries.
```css
.dynamic-container {
/* MANDATORY: Always provide a fallback for browsers that do not support calc-size() */
inline-size: fit-content;
/*
DO: Establish a dynamic size based on content, while:
1. Enforcing boundaries using CSS math functions (min, clamp, etc.)
2. Modifying the intrinsic size with fixed or relative offsets
*/
inline-size: calc-size(fit-content, min(size + var(--extra-space), var(--max-allowed)));
}
```
## Critical Considerations
- **Percentage Pitfalls**: Percentages inside the `<calc-sum>` are resolved against the **container's size**, not the `size` keyword. For example, `calc-size(auto, size + 10%)` adds 10% of the *parent's* width to the element's `auto` width, which may lead to unexpected results or overflows.
- **Calculations requirement**: **MANDATORY**: Use `interpolate-size: allow-keywords` instead of `calc-size()` for simple animations (e.g., `0` to `auto`). `calc-size()` should only be used when the layout requires dynamic mathematical adjustments to the intrinsic base.
- **Performance Note**: Animating box model properties like `inline-size` or `block-size` triggers layout recalculations, which can be expensive. Use `calc-size()` animations primarily for layout-critical elements where non-layout alternatives are insufficient.
## Fallback strategies
calc-size() has limited availability.
Supported by: Chrome 129 (Sep 2024) and Edge 129 (Sep 2024).
Unsupported in: Firefox and Safari.
interpolate-size has limited availability.
Supported by: Chrome 129 (Sep 2024) and Edge 129 (Sep 2024).
Unsupported in: Firefox and Safari.
`calc-size()` and `interpolate-size` are **progressive enhancements**. In browsers that do not support them, the properties will be ignored and the layout will remain functional, though animations to intrinsic keywords will jump instead of transitioning. Always provide a standard keyword or length as a fallback.
```css
.element {
/* Fallback for browsers that don't support calc-size() */
inline-size: fit-content;
/* Modern browsers will override the fallback */
inline-size: calc-size(fit-content, size + 2rem);
}
```
### Animation and Transition Fallbacks
In browsers without support for `calc-size()` or `interpolate-size`, transitions involving intrinsic sizing keywords will fail to interpolate.
- **Graceful Degradation**: The default fallback is an "instant jump" between states (e.g., from `0` to `auto`). This is often acceptable as the layout remains functional.
- **Enhanced Experience**: Use `@supports` to apply complex layout logic or additional styling that only makes sense when smooth intrinsic animations are possible.
- **Avoid JS-based measurements**: While you could use JavaScript to measure elements and manually animate their dimensions, this is often unnecessary and can lead to layout thrashing. Relying on the native "instant jump" is the recommended fallback for modern web applications.
For animations, the fallback experience will be an instant jump to the final size. To detect support in CSS or JavaScript:
```css
/* CSS Feature Detection */
@supports (inline-size: calc-size(auto, size + 0px)) {
.element {
/* Apply advanced logic only when supported */
}
}
```
```javascript
/* JavaScript Feature Detection */
if (CSS.supports('inline-size', 'calc-size(auto, size + 0px)')) {
// Apply advanced sizing or animations
}
```
guides/css/child-state-based-styling.md
# Child State Based Styling
Historically, CSS selectors could only traverse downwards—you could style a child based on its parent, but not a parent based on its child. The `:has()` pseudo-class changes this, allowing you to conditionally style a container element depending on the presence or state of its descendants.
By combining `:has()` with state pseudo-classes (like `:checked`, `:focus`, `:valid`, `:invalid`, or `:not()`), you can build complex, interactive UI components entirely in CSS, without needing JavaScript to toggle "modifier" classes (like `.is-active` or `.has-error`) on parent elements.
This is particularly useful for components that need to respond to internal interactions, such as a localized theme toggle reacting to a checkbox (`:checked`), a form group highlighting an error (`:invalid`), or a card elevating when a child link is focused (`:focus-within`).
### Implementing state-based container styling
**MANDATORY**: You must use the `:has()` selector on the container element to detect the specific state (e.g., `:checked`, `:focus`, `:invalid`) of its interactive child element.
To build a component that changes its styling based on a child's state:
1. **Define the default styling**: Set CSS variables on the container to define its base state.
2. **Apply state-based overrides**: Target the container with `:has([child-selector]:[state])` and redefine the CSS variables for the active or alternate state.
*Example: A component that changes theme based on a child toggle.*
```css
/* 1. Define the default state on the component container */
.theme-card {
/* Using custom properties makes state-switching cleaner */
--card-bg: #ffffff;
--card-text: #333333;
--card-border: #cccccc;
background-color: var(--card-bg);
color: var(--card-text);
border: 1px solid var(--card-border);
/* Use a transition for smooth state changes */
transition: background-color 0.3s, color 0.3s;
}
/* 2. Apply styles when the child enters the specific state */
/* MANDATORY: Target the container and use :has() to check the descendant's state */
.theme-card:has(.theme-toggle:checked) {
/* Override the properties for the active or alternate state */
--card-bg: #222222;
--card-text: #f0f0f0;
--card-border: #555555;
}
/* You can also combine :has() and :not() to target specific negative states */
/* This selects a card that DOES NOT have a toggle in the checked state */
.theme-card:not(:has(.theme-toggle:checked)) {
/* Optional: Explicit styles for the unchecked state if needed */
}
```
```html
<!-- The container element that receives the styling -->
<div class="theme-card">
<!-- The child element whose state controls the parent -->
<label>
<input type="checkbox" class="theme-toggle">
Enable Dark Mode
</label>
<h2>Card Title</h2>
<p>The style of this entire card is controlled by the checkbox above.</p>
</div>
```
**Performance tip**: When using `:has()`, scope the selector to the most specific container possible (like `.theme-card`). Avoid anchoring it to very high-level elements like `body:has(...)` if the styling changes are localized, as broad `:has()` queries can trigger more layout recalculations.
### Fallback strategies
Baseline status for :has(): Widely available. It's been Baseline since 2023-12-19.
Supported by: Chrome 105 (Sep 2022), Edge 105 (Sep 2022), Firefox 121 (Dec 2023), and Safari 15.4 (Mar 2022).
If the state-based styling is critical to the user experience or page layout, you must provide a fallback for browsers that do not support the `:has()` selector. For purely decorative effects, `:has()` can be used as a progressive enhancement without a fallback.
**MANDATORY**: When implementing a fallback for critical features, you must use `@supports not selector(:has(*))` in your CSS to define a traditional class-based fallback. If the critical state change relies on user interaction, you must also use a small inline script with `CSS.supports()` to toggle that class based on the equivalent JavaScript event (e.g., `change`, `focus`, `blur`) representing the state change.
```css
/* Fallback CSS for older browsers */
/* We check if the browser DOES NOT support the :has() selector */
@supports not selector(:has(*)) {
/* Define a traditional modifier class that applies the exact same overrides */
.theme-card.is-active {
--card-bg: #222222;
--card-text: #f0f0f0;
--card-border: #555555;
}
}
```
```javascript
/* Fallback JavaScript for older browsers */
/* Check for support before running the script to avoid unnecessary work in modern browsers */
if (!CSS.supports('selector(:has(*))')) {
const toggle = document.querySelector('.theme-toggle');
const card = document.querySelector('.theme-card');
if (toggle && card) {
// Manually toggle the fallback class when the input state changes
toggle.addEventListener('change', (e) => {
card.classList.toggle('is-active', e.target.checked);
});
}
}
```
guides/css/content-based-styling.md
# Content Based Styling
Historically, applying different layouts to a component based on its content required either JavaScript or conditional logic in your HTML templating language to inject modifier classes (like `.card--has-image` or `.card--text-only`).
The `:has()` pseudo-class eliminates this need by acting as a parent selector. It allows you to conditionally style a container element based on the presence or absence of specific descendant elements.
Using `:has()`, you can easily define distinct layout variations entirely in CSS based on a component's actual DOM content. You can also optionally combine it with `:not()` to explicitly target the *absence* of content to define default layouts.
### Implementing content-based container styling
**MANDATORY**: You must use the `:has()` selector on the container element to detect the presence of specific child content.
To build a component that changes its layout based on its content:
1. **Define the default styling**: Apply the base layout styles to the container element (e.g., a simple single-column stack).
2. **Apply content-based overrides**: Target the container with `:has([child-selector])` and apply the new layout styles for when that content is present (e.g., a multi-column grid).
*Example: A card component that switches to a side-by-side layout if an image is present.*
```css
/* 1. Define the default state on the component container */
/* This applies when there is NO image */
.article-card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.5rem;
border: 1px solid #ccc;
border-radius: 8px;
}
/* 2. Apply styles when a specific child element is present */
/* MANDATORY: Target the container and use :has() to check for the descendant */
.article-card:has(img) {
/* Change the layout to a row if an image exists */
flex-direction: row;
align-items: center;
}
/* Optional: Style the image itself (no :has() needed here) */
.article-card img {
width: 150px;
height: auto;
border-radius: 4px;
}
/* You can also combine :has() and :not() to explicitly target the ABSENCE of content */
/* This selects a card that DOES NOT have an image */
.article-card:not(:has(img)) {
/* E.g., apply a different background color for text-only cards */
background-color: #f9f9f9;
}
```
```html
<!-- Assume an <h1> precedes these components in the document layout -->
<!-- A card WITH an image (will use row layout) -->
<article class="article-card">
<img src="thumbnail.jpg" alt="Article thumbnail" />
<div class="content">
<h2>Card With Image</h2>
<p>This card lays out its content horizontally.</p>
</div>
</article>
<!-- A card WITHOUT an image (will use default column layout) -->
<article class="article-card">
<div class="content">
<h2>Text-Only Card</h2>
<p>This card lays out its content vertically, and gets its background color from the :not(:has()) rule.</p>
</div>
</article>
```
**Performance tip**: When using `:has()`, scope the selector to the most specific component container possible (like `.article-card`). Avoid anchoring it to very high-level elements like `body:has(img)` if the styling changes are highly localized, as broad `:has()` queries can trigger more layout recalculations.
### Fallback strategies
Baseline status for :has(): Widely available. It's been Baseline since 2023-12-19.
Supported by: Chrome 105 (Sep 2022), Edge 105 (Sep 2022), Firefox 121 (Dec 2023), and Safari 15.4 (Mar 2022).
If the content-based layout styling is critical to the user experience or page design, you must provide a fallback for browsers that do not support the `:has()` selector. For purely decorative effects, `:has()` can be used as a progressive enhancement without a fallback.
**MANDATORY**: When implementing a fallback for critical layouts, you must use `@supports not selector(:has(*))` in your CSS to define a traditional class-based fallback (e.g., `.has-image`).
Unlike interactive state-based styling, content presence is typically known at render time. The most robust fallback is to have your server-side templating engine (or static site generator) inject a class like `.has-image` onto the container if the child element (like an image) exists in the data.
If server-side rendering is not an option, you must use a small script with `CSS.supports()` to detect the content and append the class on load or after dynamic content injection.
```css
/* Fallback CSS for older browsers */
/* We check if the browser DOES NOT support the :has() selector */
@supports not selector(:has(*)) {
/* Define a traditional modifier class that applies the exact same layout overrides */
.article-card.has-image {
flex-direction: row;
align-items: center;
}
.article-card:not(.has-image) {
background-color: #f9f9f9;
}
}
```
```javascript
/* Fallback JavaScript for older browsers (if not using SSR to add the class) */
/* Check for support before running the script to avoid unnecessary work in modern browsers */
if (!CSS.supports('selector(:has(*))')) {
// Find all components that need checking
const cards = document.querySelectorAll('.article-card');
cards.forEach(card => {
// If the critical content exists, manually add the fallback class
if (card.querySelector('img')) {
card.classList.add('has-image');
}
});
}
```
guides/css/css-layout.md
# CSS Layouts and Responsive Design
1. [1 Fundamentals](#1-fundamentals)
1. [Which layout mode to use?](#11-which-layout-mode-to-use)
2. [Working principles](#12-working-principles)
2. [2 Flexbox](#2-flexbox)
3. [3 Grid and subgrid](#3-grid-and-subgrid)
1. [Code example: grid and subgrid](#31-code-example-grid-and-subgrid)
4. [4 Container queries](#4-container-queries)
1. [Code example: fluid typography using container query units](#41-code-example-fluid-typography-using-container-query-units)
5. [5 Native overlays, anchor positioning, and stacking contexts](#5-native-overlays-anchor-positioning-and-stacking-contexts)
6. [6 Overflow tracking and layout stability](#6-overflow-tracking-and-layout-stability)
7. [7 Viewport mechanics and track distribution](#7-viewport-mechanics-and-track-distribution)
8. [8 Grid lanes (aka masonry)](#8-grid-lanes-aka-masonry)
## 1 Fundamentals
Lean on the browser's layout engine when possible for better performance. Reach for intrinsic sizing, logical properties, and `aspect-ratio` before resorting to hardcoded dimensions or complicated media-queries.
### 1.1 Which layout mode to use?
Walk the decision tree top-to-bottom and stop at the first match. Note that layouts can be nested within each-other and each decision is based on the use-case for that container.
1. **Is it a simple row OR column of items?** Use **flexbox** — 1D, content-first, content distributes along a single axis.
2. **Does a nested element need to line up with its grandparent grid's tracks?** Use **subgrid** — 2D, relationship-first, inherits parent tracks so grandchildren can align across siblings.
3. **Is it a complex page or component structure with rows AND columns?** Use **grid** — 2D, layout-first, you define the skeleton and content fills it.
4. **Is the content a long flow of prose that should split into balanced columns?** Use **multi-column** — 1D flow, newspaper-style.
5. **Are items of varied heights that need to be packed tightly?** Use **grid** with `grid-auto-flow: dense` today; reach for native masonry (aka "grid lanes") only when it ships in your Baseline target (see [§8](#8-grid-lanes-aka-masonry)).
6. **Does an element need to float above the page and stay spatially tethered to a trigger, even across DOM boundaries or stacking contexts?** Use **anchor positioning** — `anchor-name` on the trigger, `position-anchor` on the overlay (see [§5](#5-native-overlays-anchor-positioning-and-stacking-contexts)).
### 1.2 Working principles
**Do:**
- Use logical properties (`inline-size`, `block-size`, `margin-inline`, `padding-block`, `inset-inline-start`) for layout dimensions and spacing — see `css` (via `npx -y modern-web-guidance@latest retrieve "css"`) for full coverage.
- Apply the content-first vs layout-first mental model: flexbox when items dictate flow, grid when you define the skeleton first.
- Use the `place-*` shorthands (`place-content`, `place-items`, `place-self`) to align across both axes in one declaration.
- Reach for intrinsic sizing (`min-content`, `max-content`, `fit-content()`) and flexible tracks (`fr`, `minmax()`) before fixed `width`/`height` — fewer media queries, more resilient layouts.
- Use `aspect-ratio` to reserve space for media and prevent layout shift before assets load.
```css
.sidebar { inline-size: max-content; } /* Size to longest unbreakable token. */
.main-content { inline-size: fit-content; } /* Grow to available space, no further. */
.media { aspect-ratio: 16 / 9; inline-size: 100%; block-size: auto; }
body.centered { display: grid; place-content: center; min-block-size: 100dvb; }
```
> For `calc-size()` and constraint-aware intrinsic sizing, see `calculate-with-intrinsic-sizes` (via `npx -y modern-web-guidance@latest retrieve "calculate-with-intrinsic-sizes"`).
## 2 Flexbox
One-dimensional layout — items flow along a single **main** axis with alignment on the **cross** axis. Reach for it for navbars, toolbars, item rows, and any single-row-or-column distribution.
**Do:**
- Establish a context with `display: flex` and set the main axis with `flex-direction` (`row` default).
- Use `flex-wrap: wrap` whenever overflow is a possibility — `nowrap` without `overflow: auto/hidden` will spill on narrow viewports.
- Use the `flex` shorthand `<grow> <shrink> <basis>` (e.g., `flex: 1 1 250px`) on items rather than setting `flex-grow`/`flex-shrink`/`flex-basis` individually.
- Use `gap` (or the `row-gap`/`column-gap` longhand) for spacing between items instead of child margins.
- Prefix positional alignment with `safe` (e.g., `align-items: safe center`) so focusable content isn't clipped when the container is narrower than its content.
- Push a single item to the far end of the main axis with `margin-inline-start: auto` (or `margin-block-start: auto`) — that's the standard escape hatch.
- Override cross-axis alignment per item with `align-self`.
- Use `align-items` to center all items on the cross axis; use `margin: auto` on a single item to center it on both axes independently; use `align-content` only when the container wraps and has extra space across rows.
- Set `min-inline-size: 0` (or `min-width: 0`) on flex items that contain long unbreakable content (URLs, code, long strings) — flex items won't shrink below their content size by default, causing overflow.
**Do not:**
- Don't reach for `justify-self` on flex items — it only works on grid, block, and absolutely-positioned layouts. Use auto margins instead.
- Don't use `order` or `flex-direction: *-reverse` to reorder interactive content. They change visual order only; the DOM order still drives sequential focus, so keyboard tab flow won't match what the user sees.
- Don't confuse `space-around` (half-gap at the ends) with `space-evenly` (equal gaps before, between, and after).
- Don't forget the axis flip: when `flex-direction: column`, `justify-content` aligns on the block axis and `align-items` aligns on the inline axis — the opposite of the default.
- Don't size both the container and its children to fill each other — that's a common source of overflow and surprising results. Give one side a definite size.
- Don't set both `flex-basis` and `width`/`inline-size` on the same item — `flex-basis` takes precedence in a flex context and `width` is ignored. Use `flex-basis` (or the `flex` shorthand) as the single source of truth for sizing flex items.
```css
.card-grid { display: flex; flex-flow: row wrap; gap: 1rem; }
.card-item { flex: 1 1 250px; } /* grow, shrink, basis */
.card-item-action { margin-inline-start: auto; } /* Push to main-axis end. */
.toolbar { display: flex; align-items: safe center; }
```
## 3 Grid and subgrid
Baseline status for Subgrid: Widely available. It's been Baseline since 2023-09-15.
Supported by: Chrome 117 (Sep 2023), Edge 117 (Sep 2023), Firefox 71 (Dec 2019), and Safari 16 (Sep 2022).
Two-dimensional layout — define rows AND columns explicitly, or let the engine derive them. Subgrid lets a nested grid inherit its parent's tracks so grandchildren align across siblings.
**Choosing grid features:**
- Do you know exactly how many columns you need?
- **Yes** — use explicit tracks (`grid-template-columns: 200px 1fr`, `repeat(3, 1fr)`, etc.)
- Do different columns need different sizes (sidebar + main, header spanning all)? → use `grid-template-areas` for named, readable regions
- Are all columns uniform or positioned purely by line number? → use `repeat(N, ...)` or named lines
- **No** (responsive, unknown item count) — use `repeat(auto-fit, minmax(min, 1fr))`
- Should items on the last row stretch to fill remaining space? → `auto-fit`
- Should empty last-row tracks hold their min size (preserving column ghost slots)? → `auto-fill`
- Do you need to place an item at a specific location?
- **Yes** — use `grid-column: <start> / <end>` or `grid-area: <name>`
- **No** (just spanning multiple tracks, flow position doesn't matter) — use `grid-column: span <n>`
- Do child elements need to inherit the parent grid's track sizes (ragged-edge alignment across siblings)?
- **Yes** — use subgrid on the affected axis
- Is the number of children per cell variable? → subgrid **one axis only**; use `grid-auto-rows`/`grid-auto-columns` for the other
- Is the child count fixed? → subgrid on both axes is fine
- **No** — standard grid, no subgrid needed
**Do:**
- Establish a context with `display: grid`.
- Use `grid-template-areas` for complex page-level layouts — area names are self-documenting and the declaration can be aligned in rows and columns for at-a-glance readability.
- Use `repeat(auto-fit, minmax(200px, 1fr))` for responsive card grids that stretch filled tracks to fill the row, or `auto-fill` to preserve empty repeated tracks at their min size.
- Use `fr` for proportional track distribution and `minmax(min, max)` for flexible-but-bounded tracks.
- Position items with `grid-column: span <n>` to size across tracks, `grid-column: <start> / <end>` to place at specific lines, or `grid-area: <name>` for named regions.
- Use subgrid (`grid-template-columns: subgrid` or `grid-template-rows: subgrid`) to solve the "ragged edge" problem in card lists — internal elements like titles, metadata, and CTAs line up across siblings.
- Pair a subgrid declaration with a preceding explicit `grid-template-rows`/`-columns` declaration as a same-cascade fallback for older browsers.
**Do not:**
- Don't expect `auto-fit`/`auto-fill` track size to come from item content — it comes from the `repeat()` size argument.
- Don't use `grid-auto-flow: dense` on interactive content. It packs items efficiently but reorders them visually, breaking DOM-order keyboard tab flow.
- Don't apply subgrid to both axes when the child count is variable. Extras land in the last track; use `grid-auto-rows`/`grid-auto-columns` for the implicit axis instead.
- Don't confuse `justify-items`/`align-items` (aligns item content *within its track*) with `justify-content`/`align-content` (aligns the grid tracks *within the container*). Using the wrong one silently has no effect.
- Don't use `repeat(auto-fit/auto-fill, ...)` without a definite `inline-size` on the container — inside `display: inline-grid` or an unsized flex item, the container has no width to divide, making track counts unpredictable.
### 3.1 Code example: grid and subgrid
Page shell: `<main class="page-layout">` contains `<header>`, `<aside>`, a `<section class="card-grid">` with `<div class="card">` children, and `<footer>`.
```css
/* Align grid-template-areas in rows and columns for readability. */
.page-layout {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-areas:
"header header header"
"sidebar main main"
"footer footer footer";
gap: 1.5rem;
}
header { grid-area: header; }
aside { grid-area: sidebar; }
footer { grid-area: footer; }
.card-grid {
grid-area: main;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
grid-template-rows: auto 1fr; /* title block, body block */
gap: 1rem;
}
.card {
grid-row: span 2;
display: grid;
/* Same-cascade fallback: ignored when subgrid is supported. */
grid-template-rows: auto 1fr;
grid-template-rows: subgrid;
}
```
## 4 Container queries
Baseline status for Container queries: Widely available. It's been Baseline since 2023-02-14.
Supported by: Chrome 105 (Sep 2022), Edge 105 (Sep 2022), Firefox 110 (Feb 2023), and Safari 16 (Sep 2022).
Query the size (or computed style) of an ancestor container rather than the viewport. Mental model: container queries = component context; media queries = global page layout and user preferences (`prefers-color-scheme`, `prefers-reduced-motion`).
**Do:**
- Establish a containment context with `container-type: inline-size` (width-only queries) or `container-type: size` (both axes) on a wrapper before its descendants can be queried.
- Name containers with `container-name` (or the `container` shorthand: `container: inline-size card`) when nested contexts could collide.
- Include container query units in calculating fluid type and spacing: `cqi`/`cqb` (logical inline/block), `cqw`/`cqh` (physical), `cqmin`/`cqmax`.
- Give the container a definite `block-size` whenever `container-type: size` is used — without one, descendants collapse because size containment forces the container to ignore its content.
**Do not:**
- Don't use `block-size` as a `container-type` value — it isn't valid. Use `size` for both axes.
- Don't expect children's intrinsic size to influence the container after declaring `container-type`. The container is computed as if it has no children once containment is active.
- Don't rely on container query units inside descendants of a non-qualifying ancestor; they fall back to the small viewport (`svw`/`svh`).
### 4.1 Code example: fluid typography using container query units
```css
.card-wrapper {
container: inline-size / card; /* shorthand for container-type + container-name */
}
@container card (inline-size > 400px) {
.content {
display: flex;
gap: 2rem;
}
}
.title {
/* Fluid type bound to the container width, not the viewport. */
font-size: clamp(1rem, 4cqi, 2rem);
}
```
> For component-driven responsive styling patterns, see `size-aware-styling` (via `npx -y modern-web-guidance@latest retrieve "size-aware-styling"`) and `fluid-scaling` (via `npx -y modern-web-guidance@latest retrieve "fluid-scaling"`).
## 5 Native overlays, anchor positioning, and stacking contexts
Baseline status for <dialog>: Widely available. It's been Baseline since 2022-03-14.
Supported by: Chrome 37 (Aug 2014), Edge 79 (Jan 2020), Firefox 98 (Mar 2022), and Safari 15.4 (Mar 2022).
Baseline status for Popover: Newly available. It's been Baseline since 2025-01-27.
Supported by: Chrome 116 (Aug 2023), Edge 116 (Aug 2023), Firefox 125 (Apr 2024), Safari 17 (Sep 2023), and Safari iOS 18.3 (Jan 2025).
Anchor positioning is not natively supported by any major browser yet.
**When to use each overlay primitive:**
- Use `popover` for transient, non-modal UI (flyouts, toasts, tooltips) — lives in the top layer, no `z-index` management needed.
- Use `<dialog>` with `.showModal()` for modal interactions that require focus trapping and an inert backdrop.
- Don't combine `popover` and `.showModal()` on the same element — they're mutually exclusive runtime states.
**Anchor positioning (spatial layout of overlays):**
- Use `position-area` (or `anchor()` on insets) and `anchor-size()` to position and size an overlay relative to its trigger.
- Use `position-try-fallbacks: flip-block` (or `flip-inline`) to let the browser reposition when the overlay overflows the viewport.
- Don't mix physical and logical keywords in a single `position-area` value — pick one coordinate system.
- Feature-detect with `@supports (anchor-name: --x)` and provide an absolute-position fallback.
> For full implementation detail, polyfill strategies, and `popover` value reference, see `declarative-dialog-popover-control` (via `npx -y modern-web-guidance@latest retrieve "declarative-dialog-popover-control"`) and `position-aware-tooltips` (via `npx -y modern-web-guidance@latest retrieve "position-aware-tooltips"`). For anchor positioning applied to menus and tab indicators, see `resilient-context-menus-and-nested-dropdowns` (via `npx -y modern-web-guidance@latest retrieve "resilient-context-menus-and-nested-dropdowns"`) and `anchor-positioning-tab-underline` (via `npx -y modern-web-guidance@latest retrieve "anchor-positioning-tab-underline"`).
## 6 Overflow tracking and layout stability
Baseline status for scrollbar-gutter: Newly available. It's been Baseline since 2024-12-11.
Supported by: Chrome 94 (Sep 2021), Edge 94 (Sep 2021), Firefox 97 (Feb 2022), and Safari 18.2 (Dec 2024).
line-clamp is not natively supported by any major browser yet.
Manage layout shifts, scrollbars, and clipping predictably.
**Do:**
- Use `overflow: auto` so scrollbars appear only when content actually overflows.
- Use `overflow: clip` to clip content **without** establishing a scroll container; opt into spillover with `overflow-clip-margin`.
- Use `scrollbar-gutter: stable` to reserve space for scrollbars and prevent layout shifts when content grows.
- Use `overscroll-behavior: contain` (or `none`) on scrollable containers to stop scroll chains from bubbling into the parent or document.
- Use the `-webkit-line-clamp` + `display: -webkit-box` + `-webkit-box-orient: vertical` triad for multi-line truncation — despite the prefix, this pattern is fully specified and not deprecated. Declare the unprefixed `line-clamp` shorthand alongside it; browsers that don't yet support it ignore the property harmlessly.
**Do not:**
- Don't use `overflow: scroll` when `auto` will do — `scroll` forces scrollbars even when there's nothing to scroll.
- Don't reach for `overflow: hidden` when you only want to clip — `hidden` establishes a scroll container that can be programmatically scrolled.
```css
.scrollable-list {
max-block-size: 400px;
overflow-y: auto;
scrollbar-gutter: stable; /* Reserve scrollbar space. */
overscroll-behavior: contain; /* No scroll chaining into the page. */
}
.snippet {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
line-clamp: 3; /* Ignored where unsupported. */
overflow: clip;
}
```
> For `overflow: clip` and `overflow-clip-margin` in depth, see `overflow-clipping-control` (via `npx -y modern-web-guidance@latest retrieve "overflow-clipping-control"`). For scrollbar color, sizing, and theming, see `customize-scrollbar-color-and-thickness` (via `npx -y modern-web-guidance@latest retrieve "customize-scrollbar-color-and-thickness"`), `dark-mode` (via `npx -y modern-web-guidance@latest retrieve "dark-mode"`), and `adapt-scrollbar-to-contrast-preferences` (via `npx -y modern-web-guidance@latest retrieve "adapt-scrollbar-to-contrast-preferences"`).
## 7 Viewport mechanics and track distribution
Baseline status for Small, large, and dynamic viewport units: Widely available. It's been Baseline since 2022-12-05.
Supported by: Chrome 108 (Nov 2022), Edge 108 (Dec 2022), Firefox 101 (May 2022), and Safari 15.4 (Mar 2022).
- Use `dvh`/`dvw` for mobile layout containers that must account for browser UI shifting (URL bar collapse/expand).
- Don't use `100vw` for full-width layout — it ignores scrollbar width and causes horizontal overflow. Use `100%`, `100dvw`, or `100svw` instead.
> For the full viewport unit reference (`svh`, `lvh`, `dvi`, `dvb`, etc.), see `css` (via `npx -y modern-web-guidance@latest retrieve "css"`).
## 8 Grid lanes (aka masonry)
Masonry is not natively supported by any major browser yet.
The spec is in development. The currently agreed-upon name is "grid lanes" (e.g., `display: grid-lanes`). Firefox ships `grid-template-rows: masonry` behind a flag; no other engines ship it in stable as of this writing.
**Do:**
- Use grid with `grid-auto-flow: dense` for tight packing today, accepting that DOM order may not match visual order.
- Use multi-column (`columns: 3; column-gap: 1rem`) for content-heavy masonry-like flow when items are document fragments rather than equal-weight cards.
- Treat `grid-template-rows: masonry` as a progressive enhancement only — feature-detect with `@supports`.
**Do not:**
- Don't ship `grid-template-rows: masonry` as a hard requirement until your Baseline target catches up.
```css
.gallery { columns: 3 200px; column-gap: 1rem; }
.gallery > * { break-inside: avoid; margin-block-end: 1rem; }
@supports (grid-template-rows: masonry) {
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-template-rows: masonry;
gap: 1rem;
columns: unset;
}
}
```
guides/css/css.md
# CSS: Modern Architecture and Performance
These guidelines provide a high-density reference for writing maintainable, performant, and standard-compliant CSS.
1. [1. Foundations](#1-foundations)
2. [2. Inheritance and The Cascade](#2-inheritance-and-the-cascade)
3. [3. Selectors and scoping](#3-selectors-and-scoping)
1. [Prefer CSS selectors over JS for complex element targeting](#prefer-css-selectors-over-js-for-complex-element-targeting)
2. [Use `:is()` (or `:where()`) instead of CSS rule duplication for fallbacks](#use-is-or-where-instead-of-css-rule-duplication-for-fallbacks)
3. [Avoid overmatching](#avoid-overmatching)
4. [Nesting and scoping](#nesting-and-scoping)
4. [4. Interactivity](#4-interactivity)
1. [Focus management](#focus-management)
2. [Touch targets](#touch-targets)
5. [5. Design Tokens and Theming](#5-design-tokens-and-theming)
1. [Dark mode](#dark-mode)
2. [Forced Colors Mode](#forced-colors-mode)
3. [Generating tints](#generating-tints)
4. [Theming browser-generated UI](#theming-browser-generated-ui)
6. [6. Responsive design](#6-responsive-design)
1. [Responsive Typography](#responsive-typography)
7. [7. Typography](#7-typography)
1. [Text wrapping](#text-wrapping)
8. [8. Visual effects](#8-visual-effects)
1. [Depth and texture](#depth-and-texture)
2. [Shapes](#shapes)
3. [Gradients and `color-mix()`](#gradients-and-color-mix)
4. [Patterns](#patterns)
9. [9. Transitions \& animations](#9-transitions--animations)
1. [Performance](#performance)
2. [Accessibility](#accessibility)
10. [10. Generated content](#10-generated-content)
## 1. Foundations
Be allergic to knowledge duplication. Prefer variables over repetition, but whenever possible, prefer built-in conventions such as:
- `currentColor` instead of defining a variable and setting `color` to it
- The `inherit` keyword instead of defining a variable on the parent and using it on the same property across parent and child.
- `em` units instead of `font-size: var(--size)`
- `cqw`/`cqh` (or their logical versions — `cqi`/`cqb`) units instead of repeating box model values.
- Code duplication is not knowledge duplication. The goal is robustness and maintainability, not saving characters.
- Prefer **logical properties and values** over physical ones (e.g. `margin-inline-start` instead of `margin-left`) so that styles adapt to different writing modes and orientations. Even if the page author does not plan to localize, external translation tools often display translated text in context.
- Do not use logical properties indiscriminately — ask yourself "would I want this to flip in RTL?" — if the answer is no, use the physical property instead.
- Consider different viewing modes (dark mode, high contrast mode), different viewport sizes, and different input modes (touch, keyboard, pointer).
## 2. Inheritance and The Cascade
**Avoid** introducing BEM naming conventions to manage specificity.
Instead, use modern CSS features such as cascade layers and `:where()` to make cascade behavior predictable and follow author intent.
Use cascade layers (`@layer`) to define explicit priority zones (e.g., `reset`, `base`, `theme`, `components`, `utilities`), and declare their order upfront (e.g. `@layer reset, base, theme, components, utilities;`).
Within each layer, use `:where()` to make selectors only compete based on meaningful signals, not incidental filters (`:not()` edge cases, remote ancestors, etc.) or for one-off easily overridable defaults.
Use keywords like `inherit`, `initial`, `unset`, or `revert` instead of explicit values to improve maintainability and better express intent.
Examples:
- When specifying a transition on a child that should match the parent's `transition-*` properties, instead of repeating the transition properties on the child, use `transition: inherit` (reduce duplication, improve maintainability)
- Use `initial` to reset a property to its initial value instead of specifying the value explicitly (clearer expression of intent)
## 3. Selectors and scoping
Modern browser-native selectors reduce the need for preprocessors and complex state-tracking in JS.
### Prefer CSS selectors over JS for complex element targeting
- **DO** use `:has()` to style parents based on child state instead of managing classes in JS (e.g. `label:has(:checked)` instead of a manual `label.has-checked` class) For more information, see the guides at `child-state-based-styling` (via `npx -y modern-web-guidance@latest retrieve "child-state-based-styling"`) and `content-based-styling` (via `npx -y modern-web-guidance@latest retrieve "content-based-styling"`).
- **DO NOT** nest `:has()` or use pseudo-elements inside it (browser API limitation)
- Use `:nth-child(<An+B> of <selector>)` when you need to style every n-th element of a certain type. E.g. `details:nth-child(1 of [open])` will style the first open `<details>` element it finds, whereas `details[open]:first-child` would style only the first child if and only if it was open.
### Use `:is()` (or `:where()`) instead of CSS rule duplication for fallbacks
**DO NOT** duplicate CSS rules to provide fallbacks for pseudo-classes that may not be supported — use `:is()` or `:where()` instead and take advantage of their forgiving parsing rules.
```css
/* BAD: duplicate rules instead of using `:where()` */
[popover]:popover-open {
/* styles for native popovers */
}
[popover].\:popover-open {
/* same styles again, for polyfilled popovers */
}
/* GOOD */
[popover]:where(:popover-open, .\:popover-open) {
/* same styles in one rule */
}
```
Do NOT use this for pseudo-elements, as they are not supported in `:is()` or `:where()`.
### Avoid overmatching
Write selectors in a way that expresses _intent_.
#### Use `:not()` instead of overrides to exclude irrelevant states/targets
When the intent is to exclude certain states or elements that are fundamentally irrelevant, use `:not()`.
For example, to apply bottom borders between list items, don't do this:
```css
.fancy-list li {
border-bottom: 1px solid silver;
}
.fancy-list li:last-child {
border-bottom: none;
}
```
This can unintentionally overwrite a desirable `border-bottom` set from another rule.
The actual intent was to only apply the bottom border to the non-last `li`s. The code above is a workaround that poorly expresses this intent. Instead, this expresses intent more clearly:
```css
.fancy-list li:not(:last-child) {
border-bottom: 1px solid silver;
}
```
Similarly, don't do this:
```css
button:hover {
background: var(--color-blue);
}
button:disabled {
background: var(--color-neutral);
}
```
If we reorder the two rules, we will get a hover background on disabled buttons!
Instead, do this:
```css
button:hover:not(:disabled) {
background: var(--color-blue);
}
button:disabled {
background: var(--color-neutral);
}
```
This works regardless of reordering, as the first rule does not overmatch.
#### Prefer `@scope` over `:not()` for excluding (potentially deeply nested) subtrees
While `:not()` + descendant selectors can exclude subtrees, this works poorly for deeply nested structures.
For example, `.card :not(.content *)` will not work as expected for nested cards.
`@scope` fixes this as it takes hierarchical proximity into account:
```css
@scope (.card) to (.content) {
/* styles for elements inside .card but not inside .content */
}
```
This will work as expected even for nested cards.
#### Overrides are fine for specialization
This is fine:
```css
button {
background: var(--color-neutral);
}
button.primary {
background: var(--color-blue);
}
```
Both rules express legitimate _intent_: buttons are generally neutral, but primary ones are blue.
#### No global resets
**DO NOT** use global resets (styles on `*`) as they cannot be overridden by web components or lower-priority cascade layers (without `!important`). Instead, apply reset styles to specific element types and/or conditions.
### Nesting and scoping
Use native CSS nesting to group related styles to the extent it improves maintainability and readability.
Prefer `@scope` over nesting when proximity should matter more than pure specificity. This is common in selectors that can be nested in any order, but the closest matching one (in element -> ancestor order) should win, e.g. theming classes.
For example this will not work as expected:
```css
.dark .invert { color-scheme: light }
.light .invert { color-scheme: dark }
```
If `.invert` is nested within _both_ `.dark` and `.light`, it will always resolve to dark mode as both rules have the same specificity.
Using `@scope` fixes this:
```css
@scope (.dark) {
.invert { color-scheme: light }
}
@scope (.light) {
.invert { color-scheme: dark }
}
```
## 4. Interactivity
### Focus management
- Use `:focus-visible` to define custom focus rings, not `:focus`.
- Do not remove the browser's default focus rings (via `outline: none`) without providing an alternative visible focus style.
- Prefer `outline` over other properties (e.g. `box-shadow`) for focus rings. If you must rely on `box-shadow` for focus rings, provide an `outline`-based fallback for High Contrast Mode using the `forced-colors` media query.
- Pair focus outlines with `outline-offset` to visually separate the ring from the element.
### Touch targets
- Interactive elements should be at least 24×24 CSS pixels (WCAG 2.5.8 AA). Enforce with `min-block-size` / `min-inline-size` or padding rather than `width` / `height`, so content can grow the target but not shrink it.
- Bump targets up on coarse pointers: `@media (pointer: coarse) { ... }`.
- **DON'T** use `touch-action: none` for custom gestures — it disables page scrolling through the element. Scope to the axis you actually need: `pan-y` for horizontal swipes (page still scrolls vertically), `pan-x` for vertical ones. Reserve `none` for elements where no native touch behavior makes sense (e.g. a drawing canvas).
## 5. Design Tokens and Theming
Use CSS custom properties on `:root` to define core design variables (colors, fonts, sizes, etc) used throughout the design, for visual consistency and to scale UI design across teams.
**DO NOT** specify nontrivial styling values inline. E.g. `background: transparent` or `padding: 0` is ok, but `background: #f06` or `padding: .3em` are not.
One exception is use cases where keeping code small and simple is far more important than long-term maintainability and evolution, such as testcases.
Typically these are organized in tiers, with each tier building upon the previous one. For example:
1. Tier 1: Literal design tokens (e.g. `--color-blue-10`, `--color-gray-90`, `--font-sans-serif`, `--size-xl` etc)
2. Tier 2: Semantic design tokens (e.g. `--color-accent`, `--color-neutral`, `--font-body`, `--font-heading` etc)
3. Tier 3: General UI design tokens (e.g. `--ui-border`, `--surface-bg-subtle` etc)
4. Tier 4: Component-specific design tokens (e.g. `--button-bg-primary-hover`, `--button-border-color-secondary` etc)
The smaller the scope of the use case, the fewer tiers it needs. E.g. a quick demo or toy app are fine with one tier. Do not overengineer.
Check for any existing conventions around naming and levels before inventing your own.
### Dark mode
- Use `color-scheme: light dark` on `:root` to enable dark mode support that automatically adapts to the system setting. You can also specify `color-scheme` on individual elements to force a different value for that subtree (`light`/`dark` or `light dark` for the system default)
- Use `light-dark()` to provide alternatives that automatically resolve based on the element's `color-scheme`.
Typically this happens in Tier 2 or Tier 3 tokens.
- IMPORTANT: When using `light-dark()` on an inherited `<color>` property, it will resolve to a specific color based on that element's `color-scheme` and inherit as that resolved color, not as a `light-dark()` value. It will NOT adapt to any descendant-specific `color-scheme` overrides. To keep `light-dark()` color tokens dynamic resolve them as late as possible by only passing them around as unregistered custom properties and avoid relying on inherited color values across `color-scheme` boundaries.
See `dark-mode` (via `npx -y modern-web-guidance@latest retrieve "dark-mode"`) for tips & best practices on supporting dark mode switching and `component-specific-light-dark-theme` (via `npx -y modern-web-guidance@latest retrieve "component-specific-light-dark-theme"`) for more on applying different `color-scheme` modes than the page-wide setting on certain elements.
### Forced Colors Mode
In Forced Colors Mode (High Contrast on Windows), the browser overrides author colors with system keywords and strips `background-image`, `box-shadow`, and `border-image`.
- Define system color fallbacks for color tokens using `@media (forced-colors: active)`.
- **DON'T** rely on `background-image`, `box-shadow`, or `border-image` to convey borders, separators, or state — they disappear in forced colors (and often in print too). If you must, ensure there's an alternative in forced colors mode, such as `outline` or `border` with system color keywords (`CanvasText`, `LinkText`, `ButtonText`, `Highlight`, `GrayText`, etc.).
- Use `forced-color-adjust: none` where color is essential information (syntax highlighter, color picker swatch). **DON'T** use `forced-color-adjust: none` just to preserve aesthetics.
### Generating tints
Before generating tints dynamically, check if you can use an existing, predefined, design token. This allows much more designer control and ensures consistency.
If you need to generate lighter or darker colors dynamically:
- **DO NOT** just adjust the lightness channel in `oklch`/`oklab` or `lch`/`lab`, e.g. `oklab(from var(--primary) 0.9 a b)`. While that is theoretically the correct way, browsers do not yet implement gamut mapping, so the resulting color is unpredictable.
- You can use `color-mix()` to mix with white or black (preferably in `oklab`). This keeps the color safely in gamut, but tends to over-desaturate colors and produce washed out tints and shades.
- You MAY combine lightness adjustment with any of the other methods (e.g. `color-mix(in oklab, oklch(from var(--primary) 0.9 c h), white 30%)`) for a balance between the two, but avoid going above 30% for the lightness adjustment.
### Theming browser-generated UI
Most browser-generated UI can be customized to some extent using CSS.
Even if it requires modern features, it degrades gracefully in older browsers, and thus often does not require a polyfill or fallback.
Before re-creating browser UI (form controls, scrollbars, selections, error messages, etc), first verify that:
1. the browser UI cannot be customized enough for your needs, even with modern CSS,
2. the desired customization is sufficiently critical to justify the tradeoffs of re-creating built-in UI — most notably losing accessible semantics, keyboard handling, IME, and AT integration that the native UI provides for free.
Example customizations that are possible:
- Use `::selection` to customize highlighted text colors.
- **DON'T** apply `user-select: none` to content text — breaks copy-paste, translation tools, and AT "read from here" gestures. Limit it to chrome (drag handles, toolbars, redundant button labels).
- Use `accent-color` to apply the page's accent color to any browser-generated UI.
- Use `color-scheme` to have browser UI adapt to light/dark mode.
- Use `scrollbar-color` to customize scrollbar colors and `scrollbar-width` to control scrollbar thickness — keep the thumb visibly distinct from the track (≥3:1), and don't set `scrollbar-width: none` on scrollable regions (use it only when scrolling is fully replaced by another affordance).
- Use `:user-invalid` / `:user-valid` for validity styling, **not** `:invalid` / `:valid` — they only match after the user has interacted with the field, avoiding the hostile default of flagging required-empty fields as errors on page load.
- Buttons and text fields (including `<textarea>`) can generally be styled as normal elements.
- Use `font-size` to scale and other textual properties to control typography
#### Styling textual fields (`<input>` & `<textarea>`)
For most styling purposes (e.g. colors, borders, backgrounds, typography, etc) treat these elements as normal text containers.
- Use `:placeholder-shown` and `::placeholder` to style input placeholders.
- Use `field-sizing: content` to make text fields size to content.
- For `<textarea>` elements, use `resize: vertical` to disable horizontal resizing or `resize: none` to disable all resizing.
#### Multiple choice controls (select, radios, checkboxes)
- To select one among many options presented in a dropdown: Use a `<select>` + `appearance: base-select` + `::picker(select)`. For more info see `branded-select-styling` (via `npx -y modern-web-guidance@latest retrieve "branded-select-styling"`)
- Selecting one or more among multiple options laid out inline in the page: Use a `<input type=checkbox>` or `<input type=radio>` inside a `<label>` for each option. Style via `label:has(:checked)`.
- Style checkboxes, radios and switches via `appearance: none` + generated content (`::before`/`::after`) or background images to draw the checked state.
<!-- Customizable select listbox version currently buggy + this has much better browser support -->
#### Non-textual `<input>`s (buttons, sliders, file inputs etc.)
- File inputs: Use `::file-selector-button` to style the button.
- Do not use `<input>` with a `type` of `button`, `submit` or `reset`. Use `<button>` instead and style it as a regular element.
- Sliders: Use `appearance: none` + thumb pseudo-elements (`::-webkit-slider-thumb`, `::-moz-range-thumb`, etc) and track pseudo-elements (`::-webkit-slider-runnable-track`, `::-moz-range-track`, etc) for more granular control.
## 6. Responsive design
- Use `@container` queries to create component-driven responsive layouts that adapt to their parent container's size rather than the viewport.
- Use dynamic viewport units (`dvh`, `dvw`) instead of `vh`/`vw` to prevent layout breakage when mobile browser UI elements (like address bars) appear or disappear.
- Use `aspect-ratio` for media elements (like `<img>` and `<video>`) to reserve space during loading and prevent Cumulative Layout Shift (CLS).
### Responsive Typography
- **DO** combine viewport-relative and font-relative units in `clamp()` for font sizes that scale with the viewport size while ensuring they stay within a desired range. For example, `clamp(2rem, 1rem + 5vw, 4rem)`. Adjust the proportion of viewport-relative and font-relative units to control how quickly the font-size changes.
- **DON'T** use `vw` alone for font-size without `clamp()`, as it can scale text too small or too large on extreme screens.
## 7. Typography
- Use unitless numbers for `line-height` (e.g., `1.5`) to ensure relative scaling during font-size inheritance.
- Use `overflow-wrap: break-word` (or `anywhere`) to contain long URLs.
- **DON'T** use `px` for font-size. Prefer `rem` to honor the user's browser font-size preferences (root font size), or `em` for contextual sizing.
### Text wrapping
- Use `text-wrap: balance` for balanced headlines and headline-like content (e.g. `<th>`)
- Use `text-wrap: pretty` for long-form body text (paragraphs, blockquotes, etc.)
- Use `text-wrap: balance` or `text-wrap: pretty` deliberately, **DO NOT** apply it on `*` as it does have a performance cost.
- Avoid `text-wrap: balance` on elements with a visible box (backgrounds, borders, shadows, etc) as it does not change the container's width, it only affects how text wraps *within* that width. This can leave empty space at the end of the container, which is usually undesirable.
## 8. Visual effects
### Depth and texture
- Layer multiple shadows for realistic soft depth effects.
- Use `filter: drop-shadow()` instead of `box-shadow` for non-rectangular shapes or transparent PNGs.
- Use `mix-blend-mode` and `background-blend-mode` for lighting overlays (limit scope with `isolation: isolate`)
```css
.hero {
background-image: url('texture.png'), linear-gradient(to bottom, #fff, #eee);
background-blend-mode: soft-light;
}
```
### Shapes
- Use `corner-shape: squircle` for more aesthetically pleasing curves as a progressive enhancement over regular rounded corners.
- Use elliptical `border-radius` (e.g., `10px / 20px`) for proportional curves without extra elements.
### Gradients and `color-mix()`
Use `in oklch` or `in oklab` to explicitly specify the interpolation color space for gradients or `color-mix()`.
- `in oklch` preserves chroma better, but can more easily get out of device gamut, especially for bigger differences between colors
- `in oklab` stays in gamut more easily (assuming in-gamut endpoints) but can create washed out desaturated colors in the middle, especially when interpolating between opposite hues.
- *DON'T* use `in srgb` unless you have a specific reason to do so (e.g. you are building a color picker that needs to interpolate in srgb).
#### Fallback
Some pre-2024 browsers do not support gradient color interpolation space.
To support these browsers, use the token only when its usage is safe by defining a variable:
```css
:root {
--in-oklab: ;
--in-oklch: ;
}
@supports (linear-gradient(in oklab, white, black)) {
:root {
--in-oklab: in oklab;
--in-oklch: in oklch;
}
}
```
Then use like:
```css
.card {
background: linear-gradient(to bottom var(--in-oklab), var(--accent-color), var(--darker));
}
```
- **Important:** If you use this technique, make sure there is always a non-empty gradient preamble without it, otherwise it will be a syntax error in older browsers.
- You do NOT need this for `color-mix()`. If a browser supports `color-mix()`, it also supports its `in <color-space>` argument.
### Patterns
Many patterns can be created via CSS gradients + hard stops, and these can be more flexible and performant than SVGs or external images as they can have access to CSS variables and lengths from the surrounding context.
You don't need to repeat the position twice — just use `0` or `0%` and gradient fixup will auto-adjust it.
Examples below.
Vertical stripes of `1em` width each:
```css
background: linear-gradient(to right, var(--color-1) 50%, var(--color-2) 0) 0 / 2em;
```
Diagonal stripes of `1em` width each:
```css
background: repeating-linear-gradient(-45deg, var(--color-1) 0 1em, var(--color-2) 0 2em);
```
Checkerboard pattern with `1em` squares:
```css
background: repeating-conic-gradient(var(--color-1) 0 25%, var(--color-2) 0 50%) 0 / 2em 2em;
```
Polka dot with `.5em` radius dots spaced `2em` apart (horizontally/vertically — multiply by `sqrt(2)` for diagonal distance):
```css
--distance: 2em;
--radius: .5em;
--polka: radial-gradient(circle, var(--color-1) var(--radius), transparent calc(var(--radius) + 1px));
background: var(--polka) 0 0, var(--polka) var(--distance) var(--distance) var(--color-2);
background-size: calc(var(--distance) * 2) calc(var(--distance) * 2);
```
Simple pie chart:
```css
.pie {
--p: 80%;
width: 60px;
aspect-ratio: 1;
border-radius: 50%;
background: conic-gradient(var(--color-1) var(--p), transparent 0%) var(--color-2);
}
```
**Important:** When using gradients to render charts, ensure there is a textual fallback for screen readers. MANDATORY: You MUST provide a semantic data table as an accessible alternative, as detailed in `accessibility` (via `npx -y modern-web-guidance@latest retrieve "accessibility"`) under the alternate text and media guidelines.
## 9. Transitions & animations
- Use `clip-path` and `mask-image` for custom geometric reveals and smooth fade-outs.
- Use **Scroll-Driven Animations** (`animation-timeline: scroll()`) for non-essential scroll-bound effects instead of JS listeners.
- Use **View Transitions** to animate between complex layout states seamlessly.
### Performance
Rendering performance is critical for smooth user experiences, especially in heavy DOM trees.
- Prefer to animate `opacity` and `transform` (including individual transform properties, e.g. `translate` instead of `left/right/top/bottom`) to ensure animations stay on the compositor thread.
- Use `transition-behavior: allow-discrete` + `@starting-style` to animate layout properties like `display` or `<dialog>` state natively.
- Always pair `content-visibility` with `contain-intrinsic-size` to prevent scrollbar jumps (CLS).
- When setting `contain-intrinsic-size` use the `auto` keyword and a value that’s derived from what is known about the contents (i.e. text size, spacing, size of graphics, character count). Preferably use units such as `rem`, `lh`, `cap`, or `ch` that match values used for the elements within the contents rather than `px`. If the content for items in a group is not consistently sized, then use an average size.
- Use `contain: layout style paint` to isolate component rendering updates.
#### Code Example: Render Optimization
```css
.large-section {
content-visibility: auto;
contain-intrinsic-block-size: auto 800px;
}
.row {
--row-gap: .4rem;
--title-height: 1lh;
--description-height: 0.85lh;
display: grid;
row-gap: var(--row-gap);
content-visibility: auto;
/* The sum of the title height, row gap, and description height should be the size of the contents when skipped for rendering. */
contain-intrinsic-block-size: auto calc(var(--title-height) + var(--row-gap) + var(--description-height));
}
.popover-reveal {
/* Allow discrete animations for display transitions */
transition: display 0.2s allow-discrete;
}
```
### Accessibility
Use `prefers-reduced-motion` media queries to turn off heavy motion for users who prefer it.
**DO NOT** globally apply `animation-duration: 0.01ms;` globally as it can cause certain animations to become _more_ jarring.
Either apply reduced motion versions on a case by case basis, or use a custom property like:
```css
@property --animation-reduced {
syntax: "*";
inherits: false;
initial-value: none;
}
@media (prefers-reduced-motion: reduce) {
* {
animation: var(--animation-reduced) !important;
}
}
```
Then, reduced motion versions can be kept together with the original animations:
```css
progress:not([value]) {
animation: slide 1s infinite linear;
--animation-reduced: slide 20s infinite linear;
}
```
## 10. Generated content
- **DON'T** use `content` to convey meaningful text (labels, state, instructions) — keep that in the DOM (WCAG F87). The alt text argument is harm reduction for cases where decoration accidentally carries meaning, not a license.
- Use the alternative text argument of `content` to provide alt text for screen readers. E.g. `content: url(cloud.svg) / "Save";`
- Use `content: "text" / "";` to prevent purely decorative text from being announced to screen readers.
- **DON'T** use an empty alt text argument for images — they're already presentational by default. E.g. this is wrong: `content: url(cloud.svg) / "";`.
- **DON'T** use the alt text argument to describe emojis unless the description differs from the official emoji name. E.g. don't do `content: "🎉" / "celebration";`, but `content: "🎉" / "Yay!";` is fine.
**ONLY** use the alt text argument when the text is different than the primary value and is not already present in the DOM. I.e. this is wrong:
HTML:
```html
<button class="save">Save</button>
```
CSS:
```css
button.save::before {
content: url(cloud.svg) / "Save";
}
```
A screen reader would read it out as "Save save".
guides/css/design-token-reactivity.md
# Design Token Reactivity
## Background & Overview
Often an author will need to make contextual changes to the design of a component. Historically authors would need to use selectors to apply such changes. This often meant that while many of their design tokens could exist as custom properties, higher-order design tokens could only be encoded as a selector pattern (i.e. using a class name or attribute convention) or as props/context in a JavaScript framework.
**Container style queries** allow authors to style elements based on the computed custom property value of an ancestor element. This means authors can write meaningful design token values within their stylesheets rather than relying on markup or JavaScript for presentation.
## How to implement
Implementing a reactive design token using a container style query is quite straightforward:
1. Set the higher-order design token as a custom property on a container. This does not need to be a registered custom property.
2. Use the `@container style()` rule to query the value of that custom property.
3. Apply the appropriate styles to a descendant element within the container.
A few things to keep in mind:
- The container being queried with a style query does not need to have a `container-type` or `container-name` set, however, a `container-name` can allow for more specific querying.
- The container itself cannot be styled by the container style query.
The following is a basic example of the above implementation steps.
```html
<div class="features">
<div class="card"></div>
<div class="card"></div>
</div>
<div class="bugs">
<div class="card"></div>
<div class="card"></div>
<div class="card"></div>
<div class="card"></div>
</div>
```
```css
.features {
--density: spacious;
}
.bugs {
--density: compact;
}
@container style(--density: compact) {
.card {
padding: 8px;
}
}
@container style(--density: spacious) {
.card {
padding: 24px;
}
}
```
## Fallback strategies
Baseline status for Container style queries: Newly available. It's been Baseline since 2026-05-19.
Supported by: Chrome 111 (Mar 2023), Edge 111 (Mar 2023), Firefox 151 (May 2026), and Safari 18 (Sep 2024).
Until there is Baseline support for container style queries it is NOT RECOMMENDED that they be used for core features that must be available across all browsers, since it is not simple to create a fallback for them that does not take away from their benefits or that have their own limitations. For example, if a UI density user preference is not deemed to be a core feature that must be available across all experiences, then container style queries can be use to implement the feature without a fallback.
### Using selectors instead
For core features, an alternate approach using selectors should be used. This example uses a `data-density` attribute to encode the density design token in the markup rather than as a custom property:
```html
<div class="features" data-density="spacious">
<div class="card"></div>
<div class="card"></div>
</div>
<div class="bugs" data-density="compact">
<div class="card"></div>
<div class="card"></div>
<div class="card"></div>
<div class="card"></div>
</div>
```
```css
/* This example uses `:where()` to avoid increasing specificity */
:where([data-density="compact"]) .card {
padding: var(--card-padding-compact);
}
:where([data-density="spacious"]) .card {
padding: var(--card-padding-spacious);
}
```
A major limitation of this fallback approach is that it does not support nesting elements with the `data-density` attribute set, since the selector specificity is the same, order of appearance will be used to determine the styles (i.e. `[data-density="spacious"]` will always take precedence over `[data-density="compact"]`).
### Using style queries as a progressive enhancement
While it’s NOT RECOMMENDED, if you want to use style queries as a progressive enhancement for a core feature, then to avoid duplication you can create some custom properties, then include the style queries after. Make sure the fallback approach uses `:where()` when selecting the container elements to avoid increasing the specificity.
```css
.card {
--card-padding-compact: 8px;
--card-padding-spacious: 24px;
}
:where([data-density="compact"]) .card {
padding: var(--card-padding-compact);
}
:where([data-density="spacious"]) .card {
padding: var(--card-padding-spacious);
}
/* Use style queries as a progressive enhancement: same specificity, so order of appearance is used */
@container style(--density: compact) {
.card {
padding: var(--card-padding-compact);
}
}
@container style(--density: spacious) {
.card {
padding: var(--card-padding-spacious);
}
}
```
### Feature-checking with just CSS
If you need to feature check container style queries to conditionally display some UI that relies on it, you can use a style query to do so:
```css
:root {
--style-queries-supported: check;
}
.density-toggle {
display: none;
}
@container style(--style-queries-supported) {
.density-toggle {
display: revert;
}
}
```
### Feature-checking with JavaScript
For feature-checking with JavaScript, it is slightly more complicated as the `CSSContainerRule` interface existed prior to the addition of container style queries, so it is unreliable for this purpose. Instead, you’ll need to check the computed value of a known property that is being set with a style query.
This example uses a custom property as it will have no visual effect:
```css
:root {
--style-queries-supported: check;
}
@container style(--style-queries-supported: check) {
body {
--style-queries-supported: yes;
}
}
```
Then check the computed value in JavaScript like this:
```js
if (getComputedStyle(document.body).getPropertyValue("--style-queries-supported") === "yes") {
// Use container style queries
} else {
// Use fallback strategy
}
```
guides/css/dynamic-sibling-styling.md
# Styling siblings based on count and index
Historically, applying unique styles to each sibling in a list required complex `:nth-child` loops or JavaScript to inject inline styles. Modern CSS provides `sibling-index()` and `sibling-count()` to perform these calculations directly in your stylesheet, enabling dynamic layouts and color systems that automatically adapt as elements are added or removed.
## Dynamic color systems
You can create a color spectrum across a group of siblings by calculating a unique hue or lightness value for each child. This ensures a consistent gradient effect regardless of the number of items.
```css
.swatch {
/* Calculate hue by dividing the full 360deg circle by total siblings */
/* and multiplying by the current element's 1-based index */
background-color: hsl(
calc(360deg / sibling-count() * sibling-index()),
70%,
50%
);
}
```
## Symmetrical layout and fan effects
To create symmetrical effects (like a "fan" or centering items), use the total count to find the midpoint of the list.
```css
.card {
/* Find the center index (e.g., 3 if there are 5 siblings) */
--center: calc((sibling-count() + 1) / 2);
/* Rotate items away from the center: negative for left, positive for right */
/* center element gets 0deg rotation */
transform: rotate(calc(10deg * (sibling-index() - var(--center))));
}
```
## Circular and complex positioning
By combining these functions with CSS trigonometry (`sin()`, `cos()`), you can place elements in a perfect circle without any manual coordinates.
```css
.orb {
/* Calculate the angle for this item's position on a 360deg circle */
--angle: calc(360deg / sibling-count() * sibling-index());
--radius: 150px;
/* Set the pre-transformed position for all items to be centered */
position: absolute;
place-self: center;
/* Position each element around the parent center */
transform: translate(
calc(cos(var(--angle)) * var(--radius)),
calc(sin(var(--angle)) * var(--radius))
);
}
```
### Fallback strategies
Baseline status for sibling-count() and sibling-index(): Newly available. It's been Baseline since 2026-08-18.
Supported by: Chrome 138 (Jun 2025), Edge 138 (Jun 2025), Firefox 154, and Safari 26.2 (Dec 2025).
If `sibling-index()` and `sibling-count()` are not supported, provide a fallback by injecting CSS custom properties via JavaScript. **MANDATORY:** Use feature detection with `CSS.supports()` to ensure the script only runs when necessary.
```js
/* MANDATORY: Check for native support before applying fallback */
if (!CSS.supports('top: calc(sibling-index() * 1px)')) {
const items = document.querySelectorAll('.item');
items.forEach((item, index) => {
/* MANDATORY: Injected index must be 1-based to match native function */
item.style.setProperty('--sibling-index', index + 1);
item.style.setProperty('--sibling-count', items.length);
});
}
```
In your CSS, use these variables as a base and override them with native functions inside an `@supports` block. **MANDATORY:** You MUST wrap the native function overrides in `@supports` to ensure the variables remain valid in older browsers.
```css
.item {
/* 1. Set base values using variables (from JS fallback) */
--index: var(--sibling-index);
--count: var(--sibling-count);
/* 2. Use the computed variables - replace this with your implementation-specific styles */
background-color: hsl(calc(360deg / var(--count) * var(--index)), 70%, 50%);
}
@supports (top: calc(sibling-index() * 1px)) {
.item {
/* 3. Override with native functions ONLY if supported */
--index: sibling-index();
--count: sibling-count();
}
}
```
guides/css/fluid-scaling.md
# Fluid Scaling
## Overview
Fluid scaling allows components to adjust their internal proportions (like font sizes and spacing) based on their current dimensions. This creates a more cohesive design than jumping between fixed breakpoints.
While fluid scaling was historically achieved using viewport units (scaling based on the screen size), modern container query units allow components to scale relative to their parent container instead. This ensures components look good regardless of where they are placed in a layout, promoting better component isolation and reusability.
## Implementation
### 1. Define a container
To use container query units, you must first define a containment context on a parent element.
```css
.component-wrapper {
/* Define the container type. Use 'inline-size' for width-based scaling. */
/* You can also use 'size' for both width and height, but it requires explicit sizing. */
container-type: inline-size;
/* Optional: Name the container for specific targeting */
container-name: fluid-card;
}
```
### 2. Use container query units
Use container query units (`cqi`, `cqb`, etc.) to set sizes relative to the container's dimensions.
* `cqi`: 1% of the container's inline size (width in horizontal writing modes).
* `cqb`: 1% of the container's block size (height in horizontal writing modes).
**Note**: Container units can be used directly on any property without needing an `@container` query rule. They automatically resolve based on the nearest ancestor with a defined `container-type`.
```css
.component-title {
/* Scale font size based on container width */
/* 10cqi means 10% of the container's width */
font-size: 10cqi;
}
.component-body {
/* Scale padding based on container width */
padding: 5cqi;
}
```
### 3. Constrain values with `clamp()`
To prevent sizes from becoming too small or too large, use the CSS `clamp()` function. This impacts the user's ability to zoom or adjust their base font size. To ensure text meets accessibility guidelines, the maximum size must not be more than 2.5 times the minimum size.
```css
.component-title {
/* Clamp font size between 1rem and 2.5rem, scaling with 5% of container width */
font-size: clamp(1rem, 5cqi, 2.5rem);
}
```
### Fallback strategies
Baseline status for Container queries: Widely available. It's been Baseline since 2023-02-14.
Supported by: Chrome 105 (Sep 2022), Edge 105 (Sep 2022), Firefox 110 (Feb 2023), and Safari 16 (Sep 2022).
If container queries are not supported by the browser, you should provide a fallback using viewport units or standard media queries.
```css
.component-title {
/* Fallback for browsers that do not support container units */
font-size: clamp(1rem, 5vw, 2.5rem);
}
@supports (font-size: 1cqi) {
.component-title {
/* Use container units where supported */
font-size: clamp(1rem, 5cqi, 2.5rem);
}
}
```
This fallback ensures that the text still scales, but it will be based on the screen width rather than the component's width. This should be tested to verify it works in your use case.
guides/css/individual-transform-properties.md
# Individual Transform Properties
The `transform` property allows you to apply multiple transformations in a specified order, but any changes to a single transformation require re-specifying the entire transformation chain. This makes it tricky to animate or transition a single transformation.
The individual CSS transform properties (`translate`, `rotate`, and `scale`) allow you to apply transformations independently of the `transform` property. This approach makes it simpler to override a single transformation, for instance on `:hover`.
### Key Implementation Details
Individual transform properties are always applied in a **fixed order**, regardless of their order in your CSS:
1. `translate`
2. `rotate`
3. `scale`
4. `transform` (applied last)
If you require a different order (e.g., scaling *before* rotating), you must continue using the `transform` property functions.
Transform functions do not override the individual transform properties. In other words, `scale: 2; transform: scale(3);` will first scale by 2x, then again by 3x, for a total of 6x.
### Preventing Unexpected Changes in Stacking Contexts and Containing Blocks
The `transform` property and individual transform properties impact the layout and rendering of the page and may cause unexpected behavior with the z-index or anchor positioning. MANDATORY: If an element may have a transform applied as part of a state change like `:hover`, or a transition or animation, apply an identity transformation to the base element. This ensures that the element's stacking context and containment do not change when a transform is applied.
```css
.element{
/* MANDATORY: Apply identity transformations for properties that will
change on state changes (like :hover). This prevents unexpected layout
or z-index shifts caused by creating a new stacking context only on hover. */
translate: 0px;
rotate: 0deg;
scale: 1;
}
.element:hover{
translate: 10px 10px;
rotate: 20deg;
scale: 0.8;
}
```
### Independent Animation and Transitions
The primary benefit is the ability to define overlapping animations or transitions that target different properties without conflict.
```css
.card {
/* Define independent animations that don't overwrite each other */
animation: float 3s infinite ease-in-out;
/* Transition only the scale property for hover states */
transition: scale 0.3s ease;
/* Establish the base scale to prevent a sudden stacking context shift when transitioning on hover. */
scale: 1;
}
.card:hover {
/* Only the scale changes; the 'float' animation (translate) continues uninterrupted */
scale: 1.05;
}
@keyframes float {
0%, 100% { translate: 0 0; }
50% { translate: 0 -10px; }
}
```
### Fallback strategies
Baseline status for Individual transform properties: Widely available. It's been Baseline since 2022-08-05.
Supported by: Chrome 104 (Aug 2022), Edge 104 (Aug 2022), Firefox 72 (Jan 2020), Safari 14.1 (Apr 2021), and Safari iOS 14.5 (Apr 2021).
For browsers that do not support individual transform properties, use the traditional `transform` property. Note that this requires re-declaring the entire transform stack if you want to modify one part in a different state.
```css
.element {
/* Base transform */
transform: translate(100px, 0) rotate(45deg);
/* Specify the identity for the scale property. */
scale: 1;
}
@supports not (translate: 0px) {
.element:hover {
/* Fallback: Must repeat translate and rotate even if only scale changes */
transform: translate(100px, 0) rotate(45deg) scale(1.1);
}
}
@supports (translate: 0px) {
.element:hover {
/* Modern: Only declare the change */
scale: 1.1;
}
}
```
guides/css/overflow-clipping-control.md
# Overflow Clipping Control
While `overflow: hidden` is a "blunt instrument" that almost always clips content strictly at the padding-box, `overflow: clip` combined with `overflow-clip-margin` provides the "scalpel" for fine-grained layout control across block containers.
Specify exactly where clipping occurs with `overflow: clip` and `overflow-clip-margin`. You can align the boundary precisely with inner box-model edges or extend the clipping boundary beyond the element's box by a specified offset (a safety margin). This modern approach is highly performant and eliminates the legacy requirement of adding extra wrapper containers with custom padding and negative margins just to let visual effects (like prominent child element shadows) render unclipped.
Replaced elements (`<img>`, `<video>`, `<canvas>`, etc.) default to `overflow: clip` and `overflow-clip-margin: content-box`, giving you control to cleanly contain images that use `object-fit` or `border-radius`.
## How to Implement
1. **Apply `overflow: clip`**: Ensure the target element has `overflow: clip` enabled. Setting `overflow: clip` is **mandatory** on block layout containers for `overflow-clip-margin` to take effect. If `overflow` is set to `hidden`, `auto`, or `scroll`, the `overflow-clip-margin` property is ignored by the browser. `overflow: clip` prevents all scrolling (both user-initiated and programmatic via JavaScript).
2. **Align to a Box-Edge**: Use keywords to precisely align the clipping boundary to inner box-model edges:
- `content-box`: Clips content exactly where the content area begins, leaving the padding area completely clean. Content stops right at the padding's edge. Excellent for nested layout modules.
- `padding-box` (Default): Clips content at the inner edge of the border.
- `border-box`: Clips content at the outer edge of the border, allowing content to sit under or partially overlap a translucent border.
3. **Define a Specified Offset (The Bleed)**: Provide a length value (e.g., `15px` or `5px`) to create a safety zone before cutting pixels. This allows prominent child element shadows to render unclipped past the boundary edge without expanding layout geometry.
4. **Combine Box-Edge and Offset**: Specify both a box edge and a length offset simultaneously (e.g., `content-box 15px`) to offset from a specific box edge.
## Example Code
The following examples demonstrate dynamic container layout controls, showcasing automated inner content curve nested framing and child element shadow protection alongside progressive enhancement fallbacks.
### Block Containers: Nested Rounded Curves
* Apply `overflow-clip-margin: content-box` to a parent container with rounded corners and custom padding.
* Apply similar rounded corners on inner child media and footer components along the concentric inner content box boundary, solving awkward nesting curves without custom `calc()` logic.
```html
<div class="nested-curve-parent">
<img src="avatar.jpg" alt="Nested Curve Demo">
<div class="nested-curve-footer">Card Footer</div>
</div>
```
```css
/**
* Standard block layout container with outer corner radii and padding.
* Keeps base level 1 fallback clipping roughly at the inner padding box.
*/
.nested-curve-parent {
/* Level 1 Fallback: clips child roughly at the padding box */
overflow: hidden;
}
/* Inner footer component with 12px rounded to visually demonstrate automatic concentric corner clipping */
.nested-curve-footer {
background: #111;
color: #fff;
}
@supports (overflow-clip-margin: content-box) {
.nested-curve-parent {
/* MANDATORY: overflow: clip is required on non-replaced elements */
overflow: clip;
/* Automatically curves clipping edge to match inner content-box radius */
overflow-clip-margin: content-box;
}
}
```
### Block Containers: Child Element Shadow Bleed
* Apply `overflow: clip` and define an extended `overflow-clip-margin` length offset to create a visible safety zone permitting the child's shadow to render unclipped outside the parent container without altering layout geometry. Without this, the child's shadow is clipped at the parent's boundary.
```html
<div class="safety-zone-parent">
<h4>Clipped Container</h4>
<p>Inner content boundaries safely contained.</p>
<!-- Child button element positioned inside with a prominent shadow -->
<button class="demo-glowing-btn">Submit</button>
</div>
```
```css
/**
* Standard block layout container clipping inner content.
* Base fallback uses overflow: hidden, which abruptly slices child element shadows.
*/
.safety-zone-parent {
/* Level 1 Fallback: clips overflowing content but truncates child shadows */
overflow: hidden;
}
/* Child button element positioned inside with an expanded shadow */
.demo-glowing-btn {
display: block;
box-shadow: 0 8px 13px rgba(229, 46, 113, 0.7);
}
@supports (overflow-clip-margin: 15px) {
.safety-zone-parent {
overflow: clip;
/* Establishes a visible safety zone allowing child element shadows to render safely outside */
overflow-clip-margin: 15px;
}
}
```
## Strategic Implementation & Best Practices
- **DO** apply `overflow: clip` on target elements when utilizing `overflow-clip-margin`, as setting `overflow` strictly to `clip` is **mandatory** on standard block layout containers to activate custom or inner curved clip margins.
- **DO** set `overflow-clip-margin: content-box` on padded containers with rounded corners to automatically clip unrounded internal child elements into mathematically perfect nested border curves without manual padding subtraction logic.
- **DO** configure `overflow-clip-margin` with a specified length offset when applying external visual effects (like `filter: drop-shadow()`) to prevent sharp bounding box truncation without altering or expanding layout geometry.
- **DO NOT** apply `overflow: clip` if the container requires programmatic scroll manipulation via JavaScript or serves as the immediate layout context for `position: sticky` elements, as `clip` completely disables scrolling.
## Fallback Strategies
Baseline status for overflow: clip: Widely available. It's been Baseline since 2022-09-12.
Supported by: Chrome 90 (Apr 2021), Edge 90 (Apr 2021), Firefox 81 (Sep 2020), and Safari 16 (Sep 2022).
overflow-clip-margin has limited availability.
Supported by: Firefox 148 (Feb 2026).
Unsupported in: Chrome, Edge, and Safari.
For target environments lacking native support for `overflow: clip` or `overflow-clip-margin`, progressive enhancement fallback strategies depend directly on the visual intent:
- Fallback to `overflow: hidden` as the base experience to guarantee core boundaries are maintained.
- Fallback to `overflow: visible` on elements where drop-shadows or external corner badges must not be truncated.
### Complete Progressive Enhancement Fallback Implementation
```html
<!-- 1. Nested rounded edges fallback -->
<div class="demo-container-fallback">
<img src="example.jpg" alt="Nested Curve Fallback">
<div class="demo-footer-fallback">Footer</div>
</div>
<!-- 2. Child element shadow bleed fallback -->
<div class="demo-safety-parent">
<h4>Container</h4>
<p>Inner boundaries contained.</p>
<button class="demo-glowing-btn">Submit</button>
</div>
```
```css
/**
* 1. Block Container Nested Curves Fallback
* Keeps base level 1 fallback clipping roughly at the inner padding box.
*/
.demo-container-fallback {
/* Level 1 Fallback: clip child roughly at padding box */
overflow: hidden;
}
.demo-container-fallback img {
object-fit: cover;
display: block;
}
@supports (overflow-clip-margin: content-box) {
.demo-container-fallback {
overflow: clip;
overflow-clip-margin: content-box;
}
}
/**
* 2. Child Element Shadow Bleed Fallback
* Base fallback clips content using overflow: hidden, abruptly truncating child element shadows.
*/
.demo-safety-parent {
/* Level 1 Fallback */
overflow: hidden;
}
.demo-glowing-btn {
display: block;
width: 100%;
padding: 6px 12px;
background: #e52e71;
box-shadow: 0 8px 13px rgba(229, 46, 113, 0.65);
}
@supports (overflow-clip-margin: 15px) {
.demo-safety-parent {
overflow: clip;
overflow-clip-margin: 15px;
}
}
```guides/css/reduce-style-repetition.md
# Reduce Style Repetition with CSS Functions
Maintaining large stylesheets often leads to repetitive logic, especially when dealing with design system tokens like gradients or responsive layout patterns.
The CSS `@function` at-rule allows you to encapsulate this logic into reusable, parameterized functions, making your CSS more maintainable, consistent and DRY (Don't Repeat Yourself).
## The `@function` Syntax
A custom function is defined using the `@function` rule followed by a dashed name and a list of parameters. The function returns a value using the `result` property.
```css
@function --my-function(--input1 <length>, --input2: default-value) returns <length> {
/* Logic goes here */
result: var(--input1);
}
```
### Key Concepts
- **Parameters:** Must start with a double dash (`--`).
- **Defaults:** You can provide default values using a colon (`:`).
- **Result:** The `result` property determines the value the function returns. The last `result` declared in the function body wins.
- **Scoping:** Parameters and variables defined inside the function are locally scoped.
- **Types:** You can require parameters and the returned value to match a CSS type with bracket notation (e.g., `<color>`) and allow multiple types with the `type` function (e.g., `type(<number> | <percentage>)`).
## Practical Examples
### 1. Design System Tokens (Gradients)
Ensure consistent color gradients across your app by encapsulating gradient logic. The `--angle` provides a default value to provide consistency that can be overridden.
```css
@function --fancy-gradient(--start-color <color>, --end-color <color>, --angle: 98deg) returns <image>{
result: linear-gradient(in oklab var(--angle), var(--start-color), var(--end-color) );
}
.card {
background: --fancy-gradient(#ed73d7, #5d87e9);
}
```
### 2. Conditional Layout Logic
You can use `@media` or other queries directly inside a function to return different values based on the environment. When using conditional logic in a function, note that the `@function` does not "return" at the first value of `result`, but rather follows the CSS cascade, and resolves to the last value that matches based on the screen size, container size, or other query.
```css
@function --grid-template(--count <number>){
/* MANDATORY: Put default value first. */
result: 1fr; /* Default: stack */
@media (min-width: 800px) {
result: repeat(var(--count), 1fr); /* Grid on larger screens */
}
}
main {
display: grid;
grid-template-columns: --grid-template(2);
}
```
## Best Practices
- **Use Dashed Names:** Always prefix your function names and parameters with `--`.
- **Provide Defaults:** Make your functions more robust by providing sensible default values.
- **Keep it Simple:** Use functions for logic that is actually repeated or complex. Don't over-engineer simple property-value pairs.
- **Use Types:** Ensure your parameters and return values are the expected types.
- **Consider Precompiled Alternatives:** For functions that do not depend on user input, media queries or other client-side variation, consider using a CSS precompiler to avoid doing unnecessary work on the client.
### Fallback strategies
@function has limited availability.
Supported by: Chrome 139 (Aug 2025) and Edge 139 (Aug 2025).
Unsupported in: Firefox and Safari.
In browsers that do not support CSS Functions, values set using CSS functions will be invalid. To support other browsers, provide a fallback value for the property first. This will be overridden in browsers with CSS function support.
```css
.card {
/* Provide fallback, in this case a solid color. */
background: #5d87e9;
background: --fancy-gradient(#ed73d7, #5d87e9);
}
main {
/* Provide fallback, in this case a simple stacked default. */
grid-template-columns: 1fr;
grid-template-columns: --grid-template(2);
}
```
If it is a requirement to reduce style repetition while supporting other browsers, consider a CSS precompiler with functions, like Sass.
guides/css/size-aware-styling.md
# Size Aware Styling
## Overview
Size-aware styling allows components to change their layout or appearance based on the space available to them, rather than the size of the whole screen. This is useful for components like cards or navigation bars that might be placed in different parts of a layout (like a narrow sidebar or a wide main area).
Using container queries is recommended because it makes components truly modular. You do not need to know where the component will live or write complex media queries to handle every possible layout.
## Implementation
### 1. Define the container
MANDATORY: You must first tell the browser which element is the container to be measured.
```css
.card-container {
/* Define the container type. Use 'inline-size' for width-based queries. */
/* You can also use 'size' for both width and height, but it requires explicit sizing. */
container-type: inline-size;
}
```
### 2. Apply styles based on container size
Use the `@container` rule to apply styles when the container reaches a certain size.
```css
/* Default styles for small containers (stacked layout) */
.card {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Styles for larger containers (side-by-side layout) */
/* This triggers when the container is wider than 400px */
@container (min-width: 400px) {
.card {
flex-direction: row;
align-items: center;
}
.card-image {
width: 150px;
height: 150px;
}
}
```
### 3. Conditionally show content
You can also use container queries to hide or show extra details when there is more room.
```css
.card-details {
/* Hide extra details by default in small spaces */
display: none;
}
@container (min-width: 600px) {
.card-details {
/* Show details when there is plenty of room */
display: block;
}
}
```
### Fallback strategies
Baseline status for Container queries: Widely available. It's been Baseline since 2023-02-14.
Supported by: Chrome 105 (Sep 2022), Edge 105 (Sep 2022), Firefox 110 (Feb 2023), and Safari 16 (Sep 2022).
For browsers that do not support container queries, the best approach is to use a safe default layout (like the stacked vertical layout) and progressively enhance it using media queries if the general screen size allows it.
```css
/* Default safe stacked layout */
.card {
display: flex;
flex-direction: column;
}
/* Fallback using media queries for older browsers */
@media (min-width: 600px) {
.card {
flex-direction: row;
}
}
/* Overwrite with container queries where supported */
@supports (container-type: inline-size) {
@media (min-width: 600px) {
.card {
/* Reset media query fallback if needed, or let container query handle it */
flex-direction: column;
}
}
@container (min-width: 400px) {
.card {
flex-direction: row;
}
}
}
```
This ensures that users on older browsers still get a usable layout, even if it does not adapt perfectly to every specific container width.
guides/css/style-parent-with-has.md
# Style Parent with :has()
## The Problem
Often, an error state requires styling elements *outside* the input itself—for example, changing the color of a parent `fieldset` border, highlighting the `<label>`, or showing a global error icon in the card header. Historically, this required JavaScript to toggle classes on parent elements.
## The Solution
By combining `:has()` with `:user-invalid`, we can declaratively style any ancestor based on the validity state of a specific descendant. This keeps all presentation logic in CSS.
### Implementation Strategy
1. **Selector**: Use `.parent:has(:user-invalid)` to target the container.
2. **Scope**: Be specific to avoid performance issues. Target `.field-group` rather than `body`.
3. **Fallback**: Requires JS to toggle classes on the parent if `:has()` is not supported.
## Implementation Guide
### 1. HTML Structure
```html
<form>
<div class="card-section">
<div class="header">
<h3>Profile Settings</h3>
<span class="status-icon"></span>
</div>
<div class="field">
<label for="username">Username</label>
<input type="text" id="username" required>
</div>
</div>
</form>
```
### 2. CSS
```css
/* Default State */
.card-section {
border: 1px solid #ccc;
border-left: 4px solid #ccc;
}
/*
Parent Styling Logic:
If the card contains ANY user-invalid input, turn the whole card's edge red.
*/
.card-section:has(:user-invalid) {
border-left-color: #d93025;
background-color: #fff8f8;
}
/* Change the icon too */
.card-section:has(:user-invalid) .status-icon::after {
content: "⚠️";
}
```
## Fallbacking & Browser Support
Baseline status for :user-valid and :user-invalid: Widely available. It's been Baseline since 2023-11-02.
Supported by: Chrome 119 (Oct 2023), Edge 119 (Nov 2023), Firefox 88 (Apr 2021), and Safari 16.5 (May 2023).
### CSS for Fallback
We use a class `.has-error` on the parent to mimic the `:has()` behavior.
```css
/* Native */
.card-section:has(:user-invalid) {
border-left-color: #d93025;
}
/* Fallback */
.card-section.has-error-fallback {
border-left-color: #d93025;
}
```
### JavaScript Fallback
Use a reusable utility that tracks interaction state using a `WeakMap`. This avoids polluting the DOM with "dirty" classes or data attributes.
```javascript
const UserInvalidFallback = (() => {
const dirtyState = new WeakMap();
const updateState = (input) => {
const isValid = input.checkValidity();
// Update both visual and ARIA state
input.classList.toggle('user-invalid-fallback', !isValid);
input.classList.toggle('user-valid-fallback', isValid);
if (!isValid) {
input.setAttribute('aria-invalid', 'true');
} else {
input.removeAttribute('aria-invalid');
}
};
const handleEvent = (event) => {
const input = event.target;
if (event.type === 'reset') {
const controls = input.elements || [];
for (const control of controls) {
dirtyState.delete(control);
control.classList.remove('user-invalid-fallback');
control.classList.remove('user-valid-fallback');
control.removeAttribute('aria-invalid');
}
return;
}
if (!input.checkValidity) return;
if (event.type === 'input' || event.type === 'change') {
const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
state.hasInteracted = true;
dirtyState.set(input, state);
if (state.hasBlurred) {
updateState(input);
}
} else if (event.type === 'blur') {
const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
state.hasBlurred = true;
dirtyState.set(input, state);
if (state.hasInteracted) {
updateState(input);
}
}
};
const init = (root = document) => {
if (CSS.supports('selector(:user-invalid)')) return;
root.addEventListener('blur', handleEvent, true); // Capture phase
root.addEventListener('input', handleEvent);
root.addEventListener('change', handleEvent);
root.addEventListener('reset', handleEvent, true); // Capture resets
};
return { init };
})();
// Initialize for a specific form
const form = document.querySelector('#demo-form');
UserInvalidFallback.init(form);
```
```js
// 1. Initialize the generic fallback
const form = document.querySelector('#demo-form');
UserInvalidFallback.init(form);
// 2. Add specialized "parent styling" logic (Separate from fallback)
// Listen for changes to form validity after interaction
form.addEventListener('blur', (e) => {
if (!e.target.matches('input, select, textarea')) return;
// Find the container we want to style (sync with CSS)
const container = e.target.closest('.card-section');
if (!container) return;
// Check if ANY fallbacked input in this container is invalid
const hasError = container.querySelector('.user-invalid-fallback');
container.classList.toggle('has-error-fallback', !!hasError);
}, true); // Capture phase to ensure we run after the fallback's blur listener
// Also handle input events for immediate cleanup
form.addEventListener('input', (e) => {
const container = e.target.closest('.card-section');
if (container) {
const hasError = container.querySelector('.user-invalid-fallback');
container.classList.toggle('has-error-fallback', !!hasError);
}
});
// Handle form resets
form.addEventListener('reset', () => {
form.querySelectorAll('.has-error-fallback').forEach(el => {
el.classList.remove('has-error-fallback');
});
});
```
## Other Considerations
1. **Accessibility**: Native `:user-invalid` does not automatically sync with ARIA attributes. Add the following JavaScript to keep `aria-invalid` in sync with the visual state:
```javascript
// Sync aria-invalid with the CSS :user-invalid state
const syncAria = (el) => {
el.setAttribute?.('aria-invalid', el.matches(':user-invalid') ? 'true' : 'false');
};
// Update on blur (to show error) and input (to clear it)
document.addEventListener('blur', (e) => syncAria(e.target), true);
document.addEventListener('input', (e) => {
if (e.target.hasAttribute('aria-invalid')) syncAria(e.target);
});
```
guides/css/usage-aware-component-variations.md
# Semantic Component Adaptation
Use style queries (`@container style()`) to trigger contextual variations without hardcoding what produces or reacts to each context. This reduces design system coupling and improves extensibility and component encapsulation.
For example, a `section.featured { --surface: featured }` rule can mark a context, and a landing page might also default to it via `main { --surface: featured }`. Components then opt in to react to `--surface: featured`: a card reveals a promotional badge, a button defaults to its filled variant, and so on.
Style queries let **both sides stay decoupled**: the featured surfaces never name the elements that adapt to them, and the components never inspect what produced the context. Either side can be extended independently — new components, new featured surfaces — without touching the other. Descendant selectors (e.g., `.featured .button`) force the opposite: the selector must encode both sides of the relationship and must change whenever either side does.
## When to use this guide
This guide covers *behavioral* changes: a component deciding **what to render, how to arrange itself, or which variant to present** in response to its context — hiding or showing a badge, switching layout direction, or swapping a button variant.
If instead you need *token-level* changes — density modes, themes, or other higher-order tokens that uniformly shift values like padding and color across many components — see `design-token-reactivity` (via `npx -y modern-web-guidance@latest retrieve "design-token-reactivity"`).
## Choosing the right tool
Reach for a style query when **both sides of a contextual relationship may change independently** — new elements may start producing the context, or new components may start reacting to it. The indirection pays for itself in extensibility and encapsulation, and style queries cross shadow DOM boundaries that descendant selectors cannot. When that flexibility isn't needed, prefer the simpler tool:
- **Descendant selectors** (`.toolbar .button`): a fixed, one-off relationship that isn't reusable or extensible. YAGNI applies — don't add indirection you won't use.
- **Size queries** (`@container (min-width: …)`): layout and density changes driven by available space, not semantic state.
- **Style queries** (`@container style(…)`): semantic state — what a component should render, arrange, or present in a given context.
## Implementation Directives
1. Define a semantic context flag using a CSS custom property on a container.
2. **DO NOT** set `container-type` for style queries; they query inherited custom properties on the nearest ancestor and don't require an explicit containment context.
3. **MANDATORY**: Use `@container style(--property: value)` to apply conditional styles to descendant elements. Style queries cannot match the element the property is set on — only its descendants.
4. **DO** prefer style queries over descendant selectors when the relationship must stay extensible or cross component/shadow DOM boundaries (see *Choosing the right tool* above) — but keep descendant selectors for fixed, one-off relationships where the indirection buys nothing.
## Implementation Example
```css
/* 1. Set the context on a layout container */
.featured {
--surface: featured; /* Semantic context flag for children */
}
/* 2. Component reacts to inherited flag */
.button {
/* Default: Outlined informational style */
background: transparent;
border: 1px solid currentColor;
}
.badge {
/* Default: Hidden — only revealed in featured surfaces */
display: none;
}
@container style(--surface: featured) {
.button {
/* Automatic switch to Filled promotional style */
background: var(--brand-accent);
color: white;
border: none;
}
.badge {
/* Reveal decorative elements only in featured context */
display: block;
}
}
```
## Fallback Strategies
Baseline status for Container style queries: Newly available. It's been Baseline since 2026-05-19.
Supported by: Chrome 111 (Mar 2023), Edge 111 (Mar 2023), Firefox 151 (May 2026), and Safari 18 (Sep 2024).
Until container style queries are widely available, layer them on as a progressive enhancement: ship a selector-based default that works everywhere, and let the style query override it when supported. Use `:where()` on the context selector so the style query (same specificity, later in source order) wins automatically.
```css
/* Default styles — work everywhere */
.button {
background: transparent;
border: 1px solid currentColor;
}
.badge {
display: none;
}
/* Selector-based contextual styles — broad browser support */
:where(.featured) .button {
background: var(--brand-accent);
color: white;
border: none;
}
:where(.featured) .badge {
display: block;
}
/* Progressive enhancement: style queries override when supported */
@container style(--surface: featured) {
.button {
background: var(--brand-accent);
color: white;
border: none;
}
.badge {
display: block;
}
}
```
The selector fallback ties the contextual styling to a class name, which couples the component to its DOM placement. Once style queries are widely supported, the `:where()` rule can be removed.
guides/forms/animated-select-picker.md
# Animated Select Picker
The customizable select API offers a declarative, CSS-driven way to animate `<select>` elements and their dropdown pickers. By combining `appearance: base-select` with modern CSS animation techniques—such as `@starting-style` and the `allow-discrete` transition behavior—you can create fluid, premium UI transitions for top-layer elements without relying on heavy JavaScript libraries.
Previously, animating native select dropdowns was impossible because their UI was rendered outside the accessible viewport constraints. With `appearance: base-select`, the picker becomes styleable and animatable like any other page element.
## How to Implement
To implement an animated select picker:
1. **Opt-in to customization:** Apply `appearance: base-select` to both the `<select>` element and the `::picker(select)` pseudo-element.
2. **Enable auto-sizing transitions (Optional):** Define `interpolate-size: allow-keywords` (usually on `:root`) to allow the browser to transition between discrete metric values like `height: auto` and `height: 0`.
3. **Animate the top-layer container:** Apply standard entry/exit styles to `::picker(select)`. To make sure the opacity transition works when moving between `display: none` and `display: block`, you must use `transition-behavior: allow-discrete` (often written inline as `transition: display 0.3s allow-discrete`).
4. **Hook into the opening state with `@starting-style`:** Use `@starting-style` to define the baseline styles the browser should compute *before* the transition begins. For example, if you want it to fade in, set the opacity to `0` inside the `@starting-style` block.
5. **Rotate the icon:** Use pseudo-element focus or active selectors like `:open::picker-icon` to apply transitions (such as rotation or translation) to the arrow indicator.
## Example Code: Smooth Select Scale and Fade
The following example demonstrates a custom select styled with standard page animations for the picker container.
```html
<!-- Always use a <label> linked via 'for' to the select for accessibility -->
<label for="theme-select">Visual Theme</label>
<select id="theme-select" class="animated-select" name="theme">
<!-- The <button> inside <select> becomes the visible trigger when appearance: base-select is used -->
<button>
<!-- <selectedcontent> automatically displays the content of the chosen <option> -->
<selectedcontent></selectedcontent>
</button>
<option value="system">
<!-- MANDATORY: Decorative inline SVGs MUST set aria-hidden="true" to prevent redundant screen reader announcement -->
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
System Default
</option>
<option value="light">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
Light UI
</option>
</select>
```
```css
/* Opt-in to customizable select */
.animated-select,
.animated-select::picker(select) {
appearance: base-select;
}
/* Enable auto-keyword transitions (usually set globally at :root) */
:root {
interpolate-size: allow-keywords;
}
/* Style the visible trigger and icon rotation */
.animated-select {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.875rem 1rem;
font-size: 1rem;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s ease;
}
.animated-select::picker-icon {
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.animated-select:open::picker-icon {
transform: rotate(180deg);
}
/*
* The Picker Container
* Uses top-layer animations with `allow-discrete` visibility hooks
*/
.animated-select::picker(select) {
background: white;
border-radius: 12px;
box-shadow: 0 10px 25px -3px rgba(0,0,0,0.1);
padding: 0.5rem;
margin-top: 0.25rem;
width: anchor-size(width);
overflow: hidden;
/* The crucial transition setting for popover animations */
transition:
display 0.4s allow-discrete,
overlay 0.4s allow-discrete,
opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1),
height 0.4s cubic-bezier(0.16, 1, 0.3, 1);
opacity: 0;
height: 0;
}
/* Open State */
.animated-select:open::picker(select) {
opacity: 1;
height: auto;
}
/* @starting-style to hook the transition on initial popover open */
@starting-style {
.animated-select:open::picker(select) {
opacity: 0;
height: 0;
}
}
/* Support for SVG inside Options and Selected Content */
.animated-select option svg,
.animated-select selectedcontent svg {
flex-shrink: 0; /* Prevent icons from shrinking */
width: 1.25rem;
height: 1.25rem;
}
/* MANDATORY: Provide multiple indicators (e.g. bold font and distinct background) for the checked state to avoid color-only state communication */
.animated-select option:checked {
font-weight: 700;
background-color: #f1f5f9;
}
/* Ensure copy-paste safety for users with motion sensitivities */
@media (prefers-reduced-motion: reduce) {
.animated-select::picker(select),
.animated-select::picker-icon {
transition: none !important;
}
}
```
## Strategic Implementation & Best Practices
- **DO** use `@starting-style` when you need animations to trigger exactly when an element transitions from `display: none` to visible.
- **DO NOT** use ad-hoc scroll locking. Top-layer elements managed by ‘base-select’ should allow natural backdrop dismiss behaviors.
- **DO** verify reduced motion preferences. Always wrap animation constraints in a `prefers-reduced-motion` media query to ensure accessible environments for those affected by motion sickness.
- **DO** test layout behavior. Setting `appearance: base-select` removes the default browser behavior of sizing the select based on its longest option width. You may need to set a fixed width or use flex/grid constraints to prevent layout shifts.
- **DO** ensure your `<select>` has a `name` attribute and an associated `<label>`. This ensures that even with a custom UI, the component remains accessible to screen readers and works correctly with standard form submissions.
## Fallback strategies
### Fallbacks & browser support for Customizable <select>
Customizable <select> has limited availability.
Supported by: Chrome 135 (Apr 2025) and Edge 135 (Apr 2025).
Unsupported in: Firefox and Safari.
For browsers that do not yet support `appearance: base-select`, the `<select>` element degrades gracefully to a standard operating system dropdown.
- **Non-Text Content Ignored**: Older browsers strip HTML tags (like `<svg>` or `<div>`) inside `<option>` tags and render only the text nodes. Ensure the text content of the `<option>` is readable and meaningful on its own.
- **HTML Structure Handling**: Standard parsers may ignore the `<button>` and `<selectedcontent>` tags inside `<select>` or treat them as invalid. No heavy JavaScript polyfills are strictly required for progressive enhancement if you view standard text as a readable fallback.
```javascript
document.addEventListener("DOMContentLoaded", () => {
// Check if browser supports base-select value
if (!CSS.supports("appearance", "base-select")) {
// Custom select overrides are not supported natively.
}
});
```
guides/forms/autofill-address-form.md
# Build an address form that follows best practice
Create a form that makes it as easy as possible for users to enter address data on desktop and mobile. Ensure the form makes the most of built-in browser features for autofill, validation and data entry constraints.
## How to implement
Outlined below are the most important guidelines for building successful address forms.
### Use meaningful, valid HTML
Make the most of the elements and attributes built for creating forms:
- `<form>`, `<input>`, `<label>`, and `<button>`
- `type`, `autocomplete`, and `inputmode`
These enable built-in browser functionality, improve accessibility, and add meaning to markup.
### Use the `<label>` element to label form fields for data entry
To label an `<input>`, `<select>`, or `<textarea>`, use a `<label>`. Associate a label with an input by giving the label's `for` attribute the same value as the input's `id`.
### Make the most of HTML attributes
Make it easy for users to enter data, by using the appropriate `<input>` element `<type>` attribute to provide the right keyboard on mobile and enable basic built-in validation by the browser.
Always use `type="email"` for email addresses and `type="tel"` for phone numbers.
```html
<!-- type="email"/"tel" gives mobile users the right keyboard and enables built-in validation -->
<input type="email" id="email" name="email" autocomplete="email" required>
<input type="tel" id="phone" name="phone" autocomplete="tel">
```
Every `<input>`, `<select>`, and `<textarea>` element SHOULD have an appropriate `autocomplete` attribute, to improve accessibility and help users avoid re-entering data.
### Make buttons helpful
Use `<button>` for buttons. You can also use `<input type="submit">`, but don't use a `div` or some other random element acting as a button. Button elements provide accessible behaviour, built-in form submission functionality, and can easily be styled.
Give each form submit button a value that says what it does. For each step towards checkout, use a descriptive call-to-action that shows progress and makes the next step obvious. For example, label the submit button on your delivery address form **Proceed to Payment** rather than **Continue** or **Save**.
### Use a single name input where possible
Allow your users to enter their name using a single input, unless you have a good reason for separately storing given names, family names, honorifics, or other name parts. Using a single name input makes forms less complex, enables cut-and-paste, and makes autofill simpler.
Allow international names. For validation, avoid using regular expressions that only match Latin characters. Latin-only excludes users with names or addresses that include characters that aren't in the Latin alphabet. Allow Unicode letter matching instead—and ensure your backend supports Unicode securely as both input and output. Unicode in regular expressions is well supported by modern browsers.
### Allow for a variety of address formats
When building an address form, be aware of the variety of address formats, even within a single country. Do not make assumptions about "normal" addresses.
Use a single `<textarea>` element for the street address if possible.
```html
<!-- textarea handles multi-line international address formats that split inputs can't accommodate -->
<textarea id="address" name="address" autocomplete="street-address" required></textarea>
```
This is the most flexible option for a variety of local and international address formats.
### Help save users from accidentally missing data fields
Add the `required` attribute to mandatory fields.
```html
<input type="text" id="city" name="city" autocomplete="address-level2" required>
```
### Fallback strategies
:autofill has limited availability.
Supported by: Chrome 110 (Feb 2023), Edge 110 (Feb 2023), and Safari 15 (Sep 2021).
Unsupported in: Firefox.
Autofill is a progressive enhancement. In browsers that do not support autofill, users will simply need to manually enter their address details. The semantic HTML constraints (such as `type`, `inputmode`, and `required`) will still function appropriately as standard form validation.
guides/forms/autofill-highlight-inputs.md
# Use the CSS :autofill pseudo-class to highlight form fields that have been autofilled by the browser and not edited by the user
Use the CSS `:autofill` to highlight fields that have (or have not been) autofilled, to help guide the user to successful form completion.
## How to implement
To highlight a form field that has been autofilled by the browser (and not edited by the user) add a selector to your CSS using the `:autofill` class. This can be used for an `<input>`, `<select>`, or `<textarea>` element.
When styling autofilled states, you must adhere to accessibility best practices:
- **Multiple State Indicators**: Do not rely on border color alone to indicate the autofilled state. Use multiple indicators such as border thickness and custom background shading to ensure the state is perceivable.
- **Preserve Focus Indicators**: Never remove focus outlines (`outline: none`) without providing a clear, high-contrast replacement for keyboard users.
The following example uses `:autofill` to set a custom border and background, along with explicit focus styles:
```css
input:autofill,
input:-webkit-autofill {
/* Multiple indicators: use both a distinct border and background color via box-shadow to avoid color-only state */
border: 2px solid #2e7d32;
box-shadow: 0 0 0 100vmax #e8f5e9 inset;
}
/* MANDATORY: Always provide an explicit focus indicator when styling autofilled states */
input:autofill:focus-visible,
input:-webkit-autofill:focus-visible {
outline: 3px solid #000;
outline-offset: 2px;
}
```
As shown in this example, the `box-shadow` property is used to customize the background, since `background-color` cannot be overridden directly on autofilled fields.
## Use the correct CSS pseudo-class name
**Do not** use `:auto-fill`: this is incorrect.
MANDATORY: Use `:autofill` as this is the correct pseudo-class name.
### Fallback strategies
:autofill has limited availability.
Supported by: Chrome 110 (Feb 2023), Edge 110 (Feb 2023), and Safari 15 (Sep 2021).
Unsupported in: Firefox.
The `:autofill` pseudo-class is a progressive enhancement. In browsers that do not support it, the form will still function normally, but the inputs will simply not receive the custom autofill highlighting. Users will still be able to successfully complete the form. No additional JavaScript fallback should be used.
guides/forms/autofill-payment-form.md
# Build a payment form that follows best practice
Payment forms are the single most critical part of the checkout process. Poor payment form design is a common cause of shopping cart abandonment.
Create a form that makes it as easy as possible for users to enter payment details on desktop and mobile. Ensure the form makes the most of built-in browser features for autofill, validation and data entry constraints.
## How to implement
Outlined below are the most important guidelines for building successful payment forms.
### Use meaningful, valid HTML
Make the most of the elements and attributes built for creating forms:
- `<form>`, `<input>`, `<label>`, and `<button>`
- `type`, `autocomplete`, and `inputmode`
These enable built-in browser functionality, improve accessibility, and add meaning to markup.
### Use the `<label>` element to label form fields for data entry
To label an `<input>`, `<select>`, or `<textarea>`, use a `<label>`. Associate a label with an input by giving the label's `for` attribute the same value as the input's `id`.
### Make the most of HTML attributes
Make it easy for users to enter data, by using the appropriate `<input>` element `<type>` attribute to provide the right keyboard on mobile and enable basic built-in validation by the browser.
Always use `type="email"` for email addresses and `type="tel"` for phone numbers.
```html
<!-- type="email"/"tel" gives mobile users the right keyboard and enables built-in validation -->
<input type="email" id="email" name="email" autocomplete="email" required>
<input type="tel" id="phone" name="phone" autocomplete="tel">
```
Every `<input>`, `<select>`, and `<textarea>` element SHOULD have an appropriate `autocomplete` attribute, to improve accessibility and help users avoid re-entering data.
### Make buttons helpful
Use `<button>` for buttons. You can also use `<input type="submit">`, but don't use a `div` or some other random element acting as a button. Button elements provide accessible behaviour, built-in form submission functionality, and can easily be styled.
Give each form submit button a value that says what it does. For each step towards checkout, use a descriptive call-to-action that shows progress and makes the next step obvious. For example, label the submit button on your delivery address form **Proceed to Payment** rather than **Continue** or **Save**.
### Use a single name input where possible
Allow your users to enter their name using a single input, unless you have a good reason for separately storing given names, family names, honorifics, or other name parts. Using a single name input makes forms less complex, enables cut-and-paste, and makes autofill simpler.
Allow international names. For validation, avoid using regular expressions that only match Latin characters. Latin-only excludes users with names or addresses that include characters that aren't in the Latin alphabet. Allow Unicode letter matching instead—and ensure your backend supports Unicode securely as both input and output. Unicode in regular expressions is well supported by modern browsers.
### Allow for a variety of address formats
When adding form fields for an address in a payment form, be aware of the variety of address formats, even within a single country. Do not make assumptions about "normal" addresses.
If possible within your data requirements, consider using a single `<textarea>` element for address. This is the most flexible option for a variety of local and international address formats.
### Use autocomplete for billing address
By default, set the billing address to be the same as the delivery address. Reduce visual clutter by providing a link to edit the billing address (or use summary and details elements) rather than displaying the billing address in a form.
Use appropriate autocomplete values for the billing address, just as you do for shipping address, so the user doesn't have to enter data more than once. Add a prefix word to autocomplete attributes if you have different values for inputs with the same name in different sections. For example:
```
<input autocomplete="shipping address-line-1" ...>
...
<input autocomplete="billing address-line-1" ...>
```
### Show checkout progress
For each step towards payment, use page headings and descriptive button values that make it clear what needs to be done now, and what checkout step is next.
Use the `enterkeyhint` attribute on form inputs to set the mobile keyboard enter key label. For example, use `enterkeyhint="previous"` and `enterkeyhint="next"` within a multi-page form, `enterkeyhint="done"` for the final input in the form, and `enterkeyhint="search"` for a search input.
### Help users avoid re-entering payment data
Make sure to add appropriate `autocomplete` values in payment card forms. Without autocomplete, users may keep a physical record of payment card details or store them insecurely.
```html
<!-- cc-number tells autofill this is a card number, not a generic number field -->
<!-- inputmode="numeric" gives a numeric keyboard without the increment/decrement spinner -->
<!-- DO NOT use type="number" — it adds increment/decrement controls and strips leading zeros -->
<input id="cc-number" name="cc-number" type="text" autocomplete="cc-number"
inputmode="numeric" maxlength="19" pattern="[\d ]{13,19}" required>
<!-- cc-name autofills with the name exactly as it appears on the card; Unicode pattern allows international names -->
<input id="cc-name" name="cc-name" type="text" autocomplete="cc-name"
maxlength="50" pattern="[\p{L} \-\.]+" required>
<!-- cc-exp autofills the full expiry date as MM/YY -->
<!-- MANDATORY: Place format hints above the input so autocomplete popovers or virtual keyboards do not obscure them during editing -->
<span id="exp-hint" class="hint">Format: MM/YY</span>
<input id="cc-exp" name="cc-exp" type="text" autocomplete="cc-exp"
aria-describedby="exp-hint" maxlength="5" required>
<!-- cc-csc autofills the security code; DO NOT use type="password" here -->
<input id="cc-csc" name="cc-csc" type="text" autocomplete="cc-csc"
inputmode="numeric" maxlength="4" pattern="[0-9]{3,4}" required>
```
### Use a single input for payment card and phone numbers
For payment card and phone numbers use a single input: don't split the number into parts. That makes it easier for users to enter data, makes validation simpler, and enables browsers to autofill. Consider doing the same for other numeric data such as PIN and bank codes.
### Validate carefully
You should validate data entry both in realtime and before form submission. One way to do this is by adding a pattern attribute to a payment card input. If the user attempts to submit the payment form with an invalid value, the browser displays a warning message and sets focus on the input.
However, your pattern regular expression must be flexible enough to handle the range of payment card number lengths: from 14 digits (or possibly less) to 20 (or more). Card security codes (also known as CSC, CVC, CVV, or other names) consist of 3 or 4 digits.
Allow users to include spaces when they're entering a new payment card number, since this is how numbers are displayed on physical cards. That's friendlier to the user (you won't have to tell them "they did something wrong"), less likely to interrupt conversion flow, and it's straightforward to remove spaces in numbers before processing.
### Help save users from accidentally missing data fields
Add the `required` attribute to mandatory fields. Modern browsers automatically prompt and set focus for missing data.
## Fallback strategies
Baseline status for enterkeyhint: Widely available. It's been Baseline since 2021-11-02.
Supported by: Chrome 77 (Sep 2019), Edge 79 (Jan 2020), Firefox 94 (Nov 2021), Safari 13.1 (Mar 2020), and Safari iOS 13.4 (Mar 2020).
Baseline status for Email, telephone, and URL <input> types: Widely available. It's been Baseline since 2015-07-29.
Supported by: Chrome 5 (May 2010), Edge 12 (Jul 2015), Firefox 4 (Mar 2011), Safari 5 (Jun 2010), and Safari iOS 3 (Jun 2009).
Baseline status for inputmode: Widely available. It's been Baseline since 2021-12-07.
Supported by: Chrome 66 (Apr 2018), Edge 79 (Jan 2020), Firefox 95 (Dec 2021), Safari 12.1 (Mar 2019), and Safari iOS 12.2 (Mar 2019).
Autofill is a progressive enhancement. In browsers that do not support autofill, users will simply need to manually enter their payment details. The semantic HTML constraints (such as `type`, `inputmode`, `pattern`, and `required`) will still function appropriately to validate user input and provide the correct virtual keyboards.
guides/forms/autofill-sign-in-form.md
# Build a sign-in form that follows best practice
Use cross-platform browser features to build sign-in forms that are secure, accessible and easy to use.
If users ever need to sign in to your site, then good sign-in form design is critical. This is especially true for people on poor connections, on mobile, in a hurry, or under stress. Poorly designed sign-in forms get high bounce rates. Each bounce could mean a lost customer and a disgruntled user—not just a missed sign-in opportunity.
## How to implement
Outlined below are the most important guidelines for building successful sign-in forms.
### Use meaningful, valid HTML
Make the most of the elements and attributes built for creating forms:
- `<form>`, `<input>`, `<label>`, and `<button>`
- `type`, `autocomplete`, and `inputmode`
These enable built-in browser functionality, improve accessibility, and add meaning to markup.
### Use the `<label>` element to label form fields for data entry
To label an `<input>`, `<select>`, or `<textarea>`, use a `<label>`. Associate a label with an input by giving the label's `for` attribute the same value as the input's `id`.
### Make the most of HTML attributes
Make it easy for users to enter data, by using the appropriate `<input>` element `<type>` attribute to provide the right keyboard on mobile and enable basic built-in validation by the browser.
Always use `type="email"` for email addresses and `type="tel"` for phone numbers.
Every `<input>`, `<select>`, and `<textarea>` element SHOULD have an appropriate `autocomplete` attribute, to improve accessibility and help users avoid re-entering data.
### Make buttons helpful
Use `<button>` for buttons. You can also use `<input type="submit">`, but don't use a `div` or some other random element acting as a button. Button elements provide accessible behaviour, built-in form submission functionality, and can easily be styled.
Give each form submit button a value that says what it does. Use a clear, recognizable label. For example, use **Sign In** rather than **Continue** or **Submit**.
### Use a single name input where possible
Allow your users to enter their name using a single input, unless you have a good reason for separately storing given names, family names, honorifics, or other name parts. Using a single name input makes forms less complex, enables cut-and-paste, and makes autofill simpler.
Allow international names. For validation, avoid using regular expressions that only match Latin characters. Latin-only excludes users with names or addresses that include characters that aren't in the Latin alphabet. Allow Unicode letter matching instead—and ensure your backend supports Unicode securely as both input and output. Unicode in regular expressions is well supported by modern browsers.
### Show sign-in progress
For each step towards sign-in, use page headings and descriptive button values that make it clear what needs to be done now, and what the next step is.
Use the `enterkeyhint` attribute on form inputs to set the mobile keyboard enter key label. For example, use `enterkeyhint="previous"` and `enterkeyhint="next"` within a multi-page form, `enterkeyhint="done"` for the final input in the form, and `enterkeyhint="search"` for a search input.
### Help users avoid re-entering sign-in data
Make sure to add appropriate `autocomplete` values in sign-in forms.
This enables browsers to help users by securely storing sign-in details and correctly entering form data. Without autocomplete, users may be more likely to keep a physical record of sign-in details, or store sign-in data insecurely on their device.
### Validate carefully
Validate data entry both in realtime and before form submission. Use `type="email"` for email inputs — the browser will validate the format automatically. Add the `required` attribute to mandatory fields to prevent empty submissions.
### Put sign-in in its own `<form>` element
Always use the `<form>` element when you're getting users to enter data
Don't wrap inputs in a `<div>` and handle input data submission purely with JavaScript. It's generally better to use a `<form>` element. This makes your site accessible to screenreaders and other assistive devices, enables a range of built-in browser features, makes it simpler to build basic functional sign-in for older browsers, and can still work even if JavaScript fails.
### Don't double up inputs
Some sites force users to enter emails or passwords twice. That might reduce errors for a few users, but causes extra work for all users, and increases abandonment rates. Asking twice also makes no sense where browsers autofill email addresses or suggest strong passwords. It's better to enable users to confirm their email address (you'll need to do that anyway) and make it easy for them to reset their password if necessary.
### Keep passwords private—but enable users to see them if they want
Passwords inputs should have `type="password"` to hide password text and help the browser understand that the input is for passwords. (Note that browsers use a variety of techniques to understand input roles and decide whether or not to offer to save passwords.)
You should add a **Show password** toggle to enable users to check the text they've entered—and don't forget to add a **Forgot password** link.
### Give mobile users the right keyboard
Use `<input type="email">` to give mobile users an appropriate keyboard and enable basic built-in email address validation by the browser… no JavaScript required!
If you need to use a telephone number instead of an email address, `<input type="tel">` enables a telephone keypad on mobile. You can also use the `inputmode` attribute where necessary: `inputmode="numeric"` is ideal for PIN numbers.
### Prevent mobile keyboard from obstructing the Sign in button
If you're not careful, mobile keyboards may cover your form or, worse, partially obstruct the Sign in button. Users may give up before realizing what has happened.
Where possible, avoid this by displaying only the email (or phone) and password inputs and Sign in button at the top of your sign-in page. Put other content underneath.
### Help users to avoid re-entering data
You can help browsers store data correctly and autofill inputs, so users don't have to remember to enter email and password values. This is particularly important on mobile, and crucial for email inputs, which get high abandonment rates. There are two parts to this:
1. The `autocomplete`, `name`, `id`, and `type` attributes help browsers understand the role of inputs in order to store data that can later be used for autofill. To allow data to be stored for autofill, modern browsers also require inputs to have a stable `name` or `id` value (not randomly generated on each page load or site deployment), and to be in a `<form>` element with a `submit` button.
1. The `autocomplete` attribute helps browsers correctly autofill inputs using stored data.
For email inputs use `autocomplete="username"`, since `username` is recognized by password managers in modern browsers—even though you should use `type="email"` and you may want to use `id="email"` and `name="email"`. For password inputs, use the appropriate `autocomplete` and `id` values to help browsers differentiate between new and current passwords.
### Use autocomplete="current-password" and id="current-password" for an existing password
MANDATORY: Use `autocomplete="current-password"` and `id="current-password"` for the password input in a sign-in form. This tells the browser that you want it to use the current password that it has stored for the site.
For a sign-in form:
```
<input type="password" autocomplete="current-password" id="current-password" …>
```
### Enable the browser to suggest a strong password
Modern browsers use heuristics to decide when to show the password manager UI and suggest a strong password.
Built-in browser password generators mean users and developers don't need to work out what a "strong password" is. Since browsers can securely store passwords and autofill them as necessary, there's no need for users to remember or enter passwords. Encouraging users to take advantage of built-in browser password generators also means they're more likely to use a unique, strong password on your site, and less likely to reuse a password that could be compromised elsewhere.
### Help save users from accidentally missing inputs
MANDATORY: Add the `required` attribute to both email and password fields. Modern browsers automatically prompt and set focus for missing data.
```html
<input type="email" id="email" name="email" autocomplete="username" required>
<input type="password" id="password" name="password" autocomplete="current-password" required>
```
### Allow password pasting
Some sites don't allow text to be pasted into password inputs.
Disallowing password pasting annoys users, encourages passwords that are memorable (and therefore may be easier to compromise) and, according to organizations such as the UK National Cyber Security Centre, may actually reduce security. Users only become aware that pasting is disallowed after they try to paste their password, so disallowing password pasting doesn't avoid clipboard vulnerabilities.
### Fallback strategies
Baseline status for Email, telephone, and URL <input> types: Widely available. It's been Baseline since 2015-07-29.
Supported by: Chrome 5 (May 2010), Edge 12 (Jul 2015), Firefox 4 (Mar 2011), Safari 5 (Jun 2010), and Safari iOS 3 (Jun 2009).
Baseline status for inputmode: Widely available. It's been Baseline since 2021-12-07.
Supported by: Chrome 66 (Apr 2018), Edge 79 (Jan 2020), Firefox 95 (Dec 2021), Safari 12.1 (Mar 2019), and Safari iOS 12.2 (Mar 2019).
Autofill is a progressive enhancement. In browsers that do not support autofill, users will simply need to manually enter their sign-in credentials. The semantic HTML constraints (such as `type`, `inputmode`, and `required`) will still function appropriately to validate user input and provide the correct virtual keyboards.
guides/forms/autofill-sign-up-form.md
# Build a sign-up form that follows best practice
Use cross-platform browser features to build sign-up forms that are secure, accessible and easy to use.
If users ever need to sign up to your site, then good sign-up form design is critical. This is especially true for people on poor connections, on mobile, in a hurry, or under stress. Poorly designed sign-up forms get high bounce rates. Each bounce could mean a lost customer and a disgruntled user—not just a missed sign-up opportunity.
## How to implement
Outlined below are the most important guidelines for building successful sign-up forms.
### Use meaningful, valid HTML
Make the most of the elements and attributes built for creating forms:
- `<form>`, `<input>`, `<label>`, and `<button>`
- `type`, `autocomplete`, and `inputmode`
These enable built-in browser functionality, improve accessibility, and add meaning to markup.
### Use the `<label>` element to label form fields for data entry
To label an `<input>`, `<select>`, or `<textarea>`, use a `<label>`. Associate a label with an input by giving the label's `for` attribute the same value as the input's `id`.
### Make the most of HTML attributes
Make it easy for users to enter data, by using the appropriate `<input>` element `<type>` attribute to provide the right keyboard on mobile and enable basic built-in validation by the browser.
Always use `type="email"` for email addresses and `type="tel"` for phone numbers.
Every `<input>`, `<select>`, and `<textarea>` element SHOULD have an appropriate `autocomplete` attribute, to improve accessibility and help users avoid re-entering data.
### Make buttons helpful
Use `<button>` for buttons. You can also use `<input type="submit">`, but don't use a `div` or some other random element acting as a button. Button elements provide accessible behaviour, built-in form submission functionality, and can easily be styled.
Give each form submit button a value that says what it does. Use a clear, recognizable label. For example, use **Create account** or **Sign up** rather than **Continue** or **Submit**.
### Use a single name input where possible
Allow your users to enter their name using a single input, unless you have a good reason for separately storing given names, family names, honorifics, or other name parts. Using a single name input makes forms less complex, enables cut-and-paste, and makes autofill simpler.
Allow international names. For validation, avoid using regular expressions that only match Latin characters. Latin-only excludes users with names or addresses that include characters that aren't in the Latin alphabet. Allow Unicode letter matching instead—and ensure your backend supports Unicode securely as both input and output. Unicode in regular expressions is well supported by modern browsers.
### Show sign-up progress
For each step towards sign-up, use page headings and descriptive button values that make it clear what needs to be done now, and what the next step is.
Use the `enterkeyhint` attribute on form inputs to set the mobile keyboard enter key label. For example, use `enterkeyhint="previous"` and `enterkeyhint="next"` within a multi-page form, `enterkeyhint="done"` for the final input in the form, and `enterkeyhint="search"` for a search input.
### Help users avoid re-entering sign-up data
Make sure to add appropriate `autocomplete` values in sign-up forms.
This enables browsers to help users by securely storing sign-up details and correctly entering form data. Without autocomplete, users may be more likely to keep a physical record of sign-up details, or store sign-up data insecurely on their device.
### Validate carefully
Validate data entry both in realtime and before form submission. Use `type="email"` for email inputs — the browser will validate the format automatically. For passwords, use a `pattern` attribute to enforce strength requirements and provide clear error messages when validation fails. Add the `required` attribute to mandatory fields to prevent empty submissions.
### Put sign-up in its own `<form>` element
Always use the `<form>` element when you're getting users to enter data
Don't wrap inputs in a `<div>` and handle input data submission purely with JavaScript. It's generally better to use a `<form>` element. This makes your site accessible to screenreaders and other assistive devices, enables a range of built-in browser features, makes it simpler to build basic functional sign-up for older browsers, and can still work even if JavaScript fails.
### Don't double up inputs
Some sites force users to enter emails or passwords twice. That might reduce errors for a few users, but causes extra work for all users, and increases abandonment rates. Asking twice also makes no sense where browsers autofill email addresses or suggest strong passwords. It's better to enable users to confirm their email address (you'll need to do that anyway) and make it easy for them to reset their password if necessary.
### Keep passwords private—but enable users to see them if they want
Passwords inputs should have `type="password"` to hide password text and help the browser understand that the input is for passwords. (Note that browsers use a variety of techniques to understand input roles and decide whether or not to offer to save passwords.)
You should add a **Show password** toggle to enable users to check the text they've entered—and don't forget to add a **Forgot password** link.
### Give mobile users the right keyboard
Use `<input type="email">` to give mobile users an appropriate keyboard and enable basic built-in email address validation by the browser… no JavaScript required!
If you need to use a telephone number instead of an email address, `<input type="tel">` enables a telephone keypad on mobile. You can also use the `inputmode` attribute where necessary: `inputmode="numeric"` is ideal for PIN numbers.
### Prevent mobile keyboard from obstructing the Sign up button
If you're not careful, mobile keyboards may cover your form or, worse, partially obstruct the Sign up button. Users may give up before realizing what has happened.
Where possible, avoid this by displaying only the email (or phone) and password inputs and Sign up button at the top of your sign-up page. Put other content underneath.
### Help users to avoid re-entering data
You can help browsers store data correctly and autofill inputs, so users don't have to remember to enter email and password values. This is particularly important on mobile, and crucial for email inputs, which get high abandonment rates. There are two parts to this:
1. The `autocomplete`, `name`, `id`, and `type` attributes help browsers understand the role of inputs in order to store data that can later be used for autofill. To allow data to be stored for autofill, modern browsers also require inputs to have a stable `name` or `id` value (not randomly generated on each page load or site deployment), and to be in a `<form>` element with a `submit` button.
1. The `autocomplete` attribute helps browsers correctly autofill inputs using stored data.
For email inputs use `autocomplete="username"`, since `username` is recognized by password managers in modern browsers—even though you should use `type="email"` and you may want to use `id="email"` and `name="email"`. For password inputs, use the appropriate `autocomplete` and `id` values to help browsers differentiate between new and current passwords.
### Use autocomplete="new-password" and id="new-password" for a new password
MANDATORY: For a sign-up form, use `autocomplete="new-password"`.
```html
<!-- new-password prevents password managers from auto-filling an existing password into this field -->
<input type="password" id="new-password" name="new-password" autocomplete="new-password" required>
```
### Enable the browser to suggest a strong password
Modern browsers use heuristics to decide when to show the password manager UI and suggest a strong password.
Built-in browser password generators mean users and developers don't need to work out what a "strong password" is. Since browsers can securely store passwords and autofill them as necessary, there's no need for users to remember or enter passwords. Encouraging users to take advantage of built-in browser password generators also means they're more likely to use a unique, strong password on your site, and less likely to reuse a password that could be compromised elsewhere.
### Help save users from accidentally missing inputs
Add the `required` attribute to both email and password fields. Modern browsers automatically prompt and set focus for missing data.
### Allow password pasting
Some sites don't allow text to be pasted into password inputs.
Disallowing password pasting annoys users, encourages passwords that are memorable (and therefore may be easier to compromise) and, according to organizations such as the UK National Cyber Security Centre, may actually reduce security. Users only become aware that pasting is disallowed after they try to paste their password, so disallowing password pasting doesn't avoid clipboard vulnerabilities.
### Offer third-party login
Many users prefer to sign in to websites using an email address and password sign-up form. However, you should also enable users to sign in using a third-party identity provider, also known as federated login.
This approach has several advantages. For users who create an account using federated login, you don't need to ask for, communicate, or store passwords.
You may also be able to access additional verified profile information from federated login, such as an email address—which means the user doesn't have to enter that data and you don't need to do the verification yourself. Federated login can also make it much easier for users when they get a new device.
### Take care with usernames
Don't insist on a username unless (or until) you need one. Enable users to sign up and sign in with only an email address (or telephone number) and password—or federated login if they prefer. Don't force them to choose and remember a username.
If your site does require usernames, don't impose unreasonable rules on them, and don't stop users from updating their username. On your backend you should generate a unique ID for every user account, not an identifier based on personal data such as username.
Also make sure to use `autocomplete="username"` for usernames.
### Fallback strategies
Baseline status for Email, telephone, and URL <input> types: Widely available. It's been Baseline since 2015-07-29.
Supported by: Chrome 5 (May 2010), Edge 12 (Jul 2015), Firefox 4 (Mar 2011), Safari 5 (Jun 2010), and Safari iOS 3 (Jun 2009).
Baseline status for inputmode: Widely available. It's been Baseline since 2021-12-07.
Supported by: Chrome 66 (Apr 2018), Edge 79 (Jan 2020), Firefox 95 (Dec 2021), Safari 12.1 (Mar 2019), and Safari iOS 12.2 (Mar 2019).
Autofill is a progressive enhancement. In browsers that do not support autofill, users will simply need to manually enter their sign-up credentials. The semantic HTML constraints (such as `type`, `inputmode`, and `required`) will still function appropriately to validate user input and provide the correct virtual keyboards.
guides/forms/brand-consistent-forms.md
# Brand-Consistent Forms
Customizing standard HTML form elements like checkboxes and radio buttons has historically been difficult. Developers often faced a choice between using the browser defaults or building custom components from scratch. Building custom controls is time-consuming and can easily lead to accessibility issues or missing states (like the indeterminate state for checkboxes).
The CSS property `accent-color` provides a simple way to bring your brand color to built-in HTML form inputs with a single line of CSS, without sacrificing accessibility or built-in browser features.
## How to Implement
To apply your brand color to form controls:
1. **Identify your brand color:** Choose a color that represents your brand.
2. **Apply the `accent-color` property:** Add `accent-color` to the element or a container element (like `body` or a specific form) in your CSS.
3. **Support Dark Mode (Optional but Recommended):** Use `color-scheme` to let the browser know your site supports dark mode, and adjust the `accent-color` if necessary for better contrast.
## Example Code: Brand-Consistent Form Controls
```css
:root {
--brand-color: #6200ee;
}
/* Apply accent-color to the body or a specific container */
body {
accent-color: var(--brand-color);
}
/* Optional: Adjust for dark mode if needed */
@media (prefers-color-scheme: dark) {
:root {
--brand-color: #bb86fc; /* A lighter shade for dark mode */
}
}
```
```html
<form>
<!-- Checkbox -->
<label for="subscribe">
<input type="checkbox" id="subscribe" checked>
Subscribe to newsletter
</label>
<!-- Radio Buttons -->
<label for="plan-monthly">
<input type="radio" id="plan-monthly" name="plan" value="monthly">
Monthly
</label>
<label for="plan-yearly">
<input type="radio" id="plan-yearly" name="plan" value="yearly" checked>
Yearly
</label>
<!-- Range Slider -->
<label for="volume">Volume:</label>
<input type="range" id="volume" min="0" max="100" value="70">
<!-- Progress Bar -->
<label for="file">Upload Progress:</label>
<progress id="file" max="100" value="70">70%</progress>
</form>
```
## Strategic Implementation & Best Practices
- **DO** use `accent-color` to easily theme form controls to match your brand.
- **DO NOT** blindly trust the browser to handle contrast. While browsers are supposed to automatically determine an eligible contrast color, known bugs in implementations like **Safari** (WebKit bug 244233) and **Android Chrome** (Chromium bug 343503163) can fail to invert checkmark colors, leading to invisible or hard-to-see controls when using colors that lack sufficient contrast against the background (e.g., light colors in light mode, or dark colors in dark mode).
- **DO** combine `accent-color` with `color-scheme: light dark` to ensure form controls look good in both light and dark themes.
- **DO NOT** use a color that is too close to the background color, even though browsers try to guarantee contrast, it's best to provide a color with good base contrast.
- **DO NOT** assume `accent-color` works on all form elements. Currently, it only tints `checkbox`, `radio`, `range`, and `progress` elements.
## Fallback Strategy
accent-color has limited availability.
Supported by: Chrome 93 (Aug 2021), Edge 93 (Sep 2021), Firefox 92 (Sep 2021), and Safari 26.2 (Dec 2025).
For browsers that do not support `accent-color`, the form controls fall back to the browser's default appearance. To ensure full brand consistency and high reliability across all environments, you MUST implement a custom fallback strategy using the established "visually hidden input" technique.
### Progressive Enhancement with `@supports not`
You MUST use the `@supports not` rule to apply custom fallback styles only when `accent-color` is not supported. This ensures you leverage the simplicity of `accent-color` for modern browsers while guaranteeing a consistent branded experience for older ones.
#### 1. HTML Structure
Ensure your labels wrap the text in a `<span>` to allow for sibling selectors in CSS:
```html
<label for="subscribe-fallback">
<input type="checkbox" id="subscribe-fallback" class="visually-hidden" checked>
<span>Subscribe to newsletter</span>
</label>
```
#### 2. CSS Fallback
Apply custom styles within a `@supports not` block:
```css
/* Fallback for older browsers without accent-color */
@supports not (accent-color: var(--brand-color)) {
/* Visually hide the native input using the canonical accessible recipe */
form input[type="checkbox"].visually-hidden {
position: absolute !important;
clip-path: inset(50%) !important;
overflow: hidden !important;
width: 1px !important;
height: 1px !important;
margin: -1px !important;
padding: 0 !important;
border: 0 !important;
white-space: nowrap !important;
}
/* Style the wrapper label */
label {
position: relative;
padding-left: 2rem;
cursor: pointer;
display: inline-flex;
align-items: center;
}
/* Custom box for checkbox */
input[type="checkbox"] + span::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 1.2rem;
height: 1.2rem;
border: 2px solid #ccc;
background: white;
border-radius: 4px;
box-sizing: border-box;
transition: all 0.2s ease;
}
/* Ensure custom checkbox shows focus for keyboard users */
input[type="checkbox"]:focus-visible + span::before {
outline: 2px solid #000;
outline-offset: 2px;
}
/* Checked State */
input[type="checkbox"]:checked + span::before {
background-color: var(--brand-color, #6200ee);
border-color: var(--brand-color, #6200ee);
}
/* Checkmark (Unicode) */
input[type="checkbox"]:checked + span::after {
content: "✓";
position: absolute;
left: 0.25rem;
top: 50%;
transform: translateY(-50%);
color: white;
font-weight: bold;
font-size: 0.9rem;
}
/* Fallback for Range Slider */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
background: transparent;
}
/* Webkit (Chrome, Safari, Edge) */
input[type="range"]::-webkit-slider-runnable-track {
width: 100%;
height: 8px;
/* Use gradient to show progress for a static value (e.g., 70%) or update with JS */
background: linear-gradient(to right, var(--brand-color, #6200ee) 70%, #ccc 70%);
border-radius: 4px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: var(--brand-color, #6200ee);
cursor: pointer;
margin-top: -4px; /* Center vertically */
}
/* Firefox */
input[type="range"]::-moz-range-track {
width: 100%;
height: 8px;
background: #ccc;
border-radius: 4px;
}
input[type="range"]::-moz-range-thumb {
height: 16px;
width: 16px;
border-radius: 50%;
background: var(--brand-color, #6200ee);
cursor: pointer;
}
/* Firefox specific progress bar */
input[type="range"]::-moz-range-progress {
background-color: var(--brand-color, #6200ee);
height: 8px;
border-radius: 4px;
}
/* Fallback for Progress Bar */
progress {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: none;
background: #ccc;
height: 8px;
border-radius: 4px;
}
progress::-webkit-progress-bar {
background-color: #ccc;
border-radius: 4px;
}
progress::-webkit-progress-value {
background-color: var(--brand-color, #6200ee);
border-radius: 4px;
}
progress::-moz-progress-bar {
background-color: var(--brand-color, #6200ee);
border-radius: 4px;
}
}
```
### Dynamic Range Progress in Webkit Fallback
To make the progress fill move with the thumb on a range slider in Webkit browsers (without `accent-color`), you can use a CSS variable and a small amount of JavaScript.
1. **Update CSS**: Use a CSS variable for the gradient stop:
```css
input[type="range"]::-webkit-slider-runnable-track {
background: linear-gradient(to right, var(--brand-color) var(--progress, 0%), #ccc var(--progress, 0%));
}
```
2. **Add JavaScript**: Update the variable on the `input` event:
```javascript
if (!CSS.supports('accent-color')) {
const slider = document.getElementById('volume');
slider.addEventListener('input', (e) => {
e.target.style.setProperty('--progress', `${e.target.value}%`);
});
}
```
guides/forms/branded-select-styling.md
# Branded Select Styling
The customizable select API offers a declarative, CSS-driven way to style `<select>` elements to perfectly match your brand's design system. By opting into `appearance: base-select`, you gain access to the internal shadow DOM of the select element, allowing you to style the button, the options picker list, the arrow icon, and the checkmark indicator using standard CSS properties.
Previously, achieving a fully branded select required rebuilding the control from scratch with JavaScript, which often broke accessibility, keyboard navigation, and native form integration. With `appearance: base-select`, you get a custom look while the browser handles focus management, top-layer rendering, and accessibility bindings.
## How to Implement
To implement branded select styling:
1. **Opt-in to customization:** Apply `appearance: base-select` to both the `<select>` element and the `::picker(select)` pseudo-element (which targets the drop-down list of options).
2. **Structure the custom button (Optional):** Define a `<button>` element directly inside the `<select>` to replace the default trigger. Use the `<selectedcontent>` element inside this button to represent the text or content of the currently selected option.
3. **Style the Picker List:** Use the `::picker(select)` pseudo-element to apply typography, background colors, borders, and shadows to the dropdown list. The browser renders this in the top-layer, making `z-index` conflicts a thing of the past.
4. **Style Internal Icons:**
- Use `select::picker-icon` to style or replace the arrow icon.
- Use `option::checkmark` to style the checkmark indicator next to the active option.
5. **Style Options:** Apply styles to `<option>` elements for hover states, padding, and layout.
## Example Code: Branded Courier Select
The following example demonstrates a custom select styled with a monospace font and dashed borders to match a specific "parcel" brand aesthetic.
```css
/* Enable customization for the select and its picker */
.brand-select,
.brand-select::picker(select) {
appearance: base-select;
}
/* Style the visible trigger button */
.brand-select {
font-family: 'Courier New', monospace;
background-color: #fffaf0;
color: #8b4513;
border: 2px dashed #8b4513;
border-radius: 4px;
padding: 0.75rem;
font-size: 1rem;
cursor: pointer;
}
/* Style the dropdown options list */
.brand-select::picker(select) {
font-family: 'Courier New', monospace;
background-color: #fffaf0;
border: 2px dashed #8b4513;
border-radius: 4px;
padding: 0.5rem;
}
/* Customize internal part colors to match text */
.brand-select::picker-icon {
color: #8b4513;
}
.brand-select option::checkmark {
color: #8b4513;
}
/* Style individual options and hover effects */
.brand-select option {
padding: 0.5rem;
border-radius: 4px;
color: #8b4513;
cursor: pointer;
}
.brand-select option:hover {
background-color: #fdf5e6;
}
```
```html
<label for="preferences">Select shipping preference</label>
<select class="brand-select" id="preferences" name="preferences">
<button>
<selectedcontent></selectedcontent>
</button>
<option value="standard">Standard Shipping</option>
<option value="express" selected>Express Shipping</option>
<option value="overnight">Overnight Delivery</option>
</select>
```
## Strategic Implementation & Best Practices
- **DO** use `appearance: base-select` when your design system requires high-fidelity, visual consistency across all form controls that cannot be achieved with standard cross-browser select overrides.
- **DO NOT** use this if you rely on the operating system's native picker experience (e.g., the standard scroll wheel picker on iOS devices). Opting into `base-select` opts out of native mobile UI controls in favor of web-rendered top-layer menus.
- **DO** verify that color contrast meets WCAG standards. The customizable picker allows you to set ad-hoc colors, but you are responsible for ensuring text remains legible against custom backgrounds.
- **DO** test layout behavior. Setting `appearance: base-select` removes the default browser behavior of sizing the select based on its longest option width. You may need to set a fixed width or use flex/grid constraints to prevent layout shifts.
- **DO** ensure your `<select>` has a `name` attribute and an associated `<label>`. This ensures that even with a custom UI, the component remains accessible to screen readers and works correctly with standard form submissions.
## Fallback strategies
### Fallbacks & browser support for Customizable <select>
Customizable <select> has limited availability.
Supported by: Chrome 135 (Apr 2025) and Edge 135 (Apr 2025).
Unsupported in: Firefox and Safari.
For browsers that do not yet support `appearance: base-select`, the `<select>` element degrades gracefully to a standard operating system dropdown.
- **Non-Text Content Ignored**: Older browsers strip HTML tags (like `<svg>` or `<div>`) inside `<option>` tags and render only the text nodes. Ensure the text content of the `<option>` is readable and meaningful on its own.
- **HTML Structure Handling**: Standard parsers may ignore the `<button>` and `<selectedcontent>` tags inside `<select>` or treat them as invalid. No heavy JavaScript polyfills are strictly required for progressive enhancement if you view standard text as a readable fallback.
```javascript
document.addEventListener("DOMContentLoaded", () => {
// Check if browser supports base-select value
if (!CSS.supports("appearance", "base-select")) {
// Custom select overrides are not supported natively.
}
});
```
guides/forms/custom-select-picker-layouts.md
# Custom Select Picker Layouts
"Custom Select Picker Layouts" allow developers to break away from the traditional vertical list of options in a `<select>` dropdown. Using `appearance: base-select` and the `::picker(select)` pseudo-element, you can style the options list using modern CSS layout techniques like Grid or Flexbox. This is ideal for color pickers, emoji selectors, or product variants where a visual menu is more effective than a list.
The CSS property `appearance: base-select` unlocks the ability to style the internal parts of a `<select>` element. By targeting `select::picker(select)`, you can apply `display: grid` and position options in columns, creating a rich visual experience without custom JavaScript.
## How to Implement
To implement a custom select picker layout:
1. **Activate Base Styling:** Apply `appearance: base-select` to both the `<select>` element and its internal picker pseudo-element `select::picker(select)`.
2. **Style the Picker Container:** Target `select::picker(select)` and apply `display: grid` (or `display: flex`). Define columns and gaps as you would for any container.
3. **Style Options:** Target the `<option>` elements to style them as grid items or cards. You can add images, SVGs, or complex layouts inside them.
4. **Customize the Trigger (Optional):** Use `<selectedcontent>` inside a `<button>` to render rich content for the selected value.
## Example Code: Custom Grid Picker
```html
<label for="weather-picker">Select weather</label>
<select class="grid-picker" name="weather" id="weather-picker">
<button>
<selectedcontent></selectedcontent>
</button>
<option value="sunny">
<span class="icon">☀️</span>
<span class="label">Sunny</span>
</option>
<option value="cloudy">
<span class="icon">☁️</span>
<span class="label">Cloudy</span>
</option>
<!-- More options... -->
</select>
```
```css
/* Activate the customizable select state */
.grid-picker,
.grid-picker::picker(select) {
appearance: base-select;
}
/* Style the dropdown list as a grid */
.grid-picker::picker(select) {
display: grid;
grid-template-columns: repeat(2, 1fr); /* 2 columns */
gap: 10px;
padding: 15px;
background: white;
border: 1px solid #ccc;
border-radius: 8px;
}
/* Style options as grid cards */
.grid-picker option {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 15px;
border: 1px solid #eee;
border-radius: 6px;
}
/* MANDATORY: Use multiple visual indicators (distinct border thickness, background shift, and font weight) for the checked state to avoid color-only state communication */
.grid-picker option:checked {
border: 2px solid #007bff;
background-color: #f0f7ff;
font-weight: 700;
}
```
## Strategic Implementation & Best Practices
- **DO** use `appearance: base-select` when you need to change the visual layout of options from a standard list to a 2D grid or custom flex flow.
- **DO NOT** assume the styles apply to all browsers equally yet; verify support and provide a fallback.
- **MANDATORY Accessibility Routing**: A native `<select>` element enforces one-dimensional linear arrow-key navigation (Up/Down). Arranging options in a 2D visual grid creates a spatial mismatch where pressing Left/Right arrows does not move focus horizontally between adjacent columns. If true two-dimensional keyboard navigation is essential for usability, do not use a native `<select>`; implement a custom ARIA `role="listbox"` composite widget with manual JavaScript matrix-based arrow key navigation instead.
- **DO** use `<selectedcontent>` if you want the trigger button to show images or icons from the selected option automatically.
- **DO** use standard `value` attributes on options to ensure form submission works exactly as before.
- **DO** ensure your `<select>` has a `name` attribute and an associated `<label>`. This ensures that even with a custom UI, the component remains accessible to screen readers and works correctly with standard form submissions.
## Fallback Strategy
### Fallbacks & browser support for Customizable <select>
Customizable <select> has limited availability.
Supported by: Chrome 135 (Apr 2025) and Edge 135 (Apr 2025).
Unsupported in: Firefox and Safari.
For browsers that do not yet support `appearance: base-select`, the `<select>` element degrades gracefully to a standard operating system dropdown.
- **Non-Text Content Ignored**: Older browsers strip HTML tags (like `<svg>` or `<div>`) inside `<option>` tags and render only the text nodes. Ensure the text content of the `<option>` is readable and meaningful on its own.
- **HTML Structure Handling**: Standard parsers may ignore the `<button>` and `<selectedcontent>` tags inside `<select>` or treat them as invalid. No heavy JavaScript polyfills are strictly required for progressive enhancement if you view standard text as a readable fallback.
```javascript
document.addEventListener("DOMContentLoaded", () => {
// Check if browser supports base-select value
if (!CSS.supports("appearance", "base-select")) {
// Custom select overrides are not supported natively.
}
});
```guides/forms/form-fields-automatically-fit-contents.md
# Form Fields Automatically Fit Contents
By default, form controls like `<input>`, `<textarea>`, and `<select>` have fixed dimensions. Their sizes remain constant, regardless of the amount of content the user enters or selects.
To allow these controls to automatically shrink or grow to fit their content (including placeholders), use the `field-sizing: content` CSS property.
### Auto-sizing form controls
Setting `field-sizing: content` on inputs, selects, or textareas allows them to resize dynamically as the user types or selects options. However, you must account for inherited styling, layout defaults, and minimum/maximum constraints to ensure a robust user experience.
To prevent layout issues, it is recommended to set both `min-inline-size` (or `min-width`) and `max-inline-size` (or `max-width`) alongside `field-sizing: content` on text inputs. A minimum size prevents the input from collapsing to a width of zero when empty (making it unclickable), and a maximum size ensures it doesn't expand indefinitely and break the page layout.
For textareas, allowing horizontal auto-sizing can cause a jarring UX (e.g., a textarea with a long placeholder will abruptly shrink horizontally when the user types a single character). To prevent this, apply an explicit width (like `width: 100%`) to the textarea. This forces the textarea to maintain a stable horizontal width while still auto-sizing vertically as content wraps.
It is recommended to set a `max-inline-size` (or `max-width`) on `<select>` elements using `field-sizing: content` to ensure that extremely long selected options do not break the page layout.
```css
/* Applies horizontal auto-sizing to inputs */
input {
/* Instructs the element to size itself to fit its content */
field-sizing: content;
/* Reset explicit width if inherited from global styles */
width: auto;
/* Prevents the input from collapsing and disappearing when empty */
min-inline-size: 15ch;
/* Prevents infinite horizontal growth */
max-inline-size: 50ch;
}
textarea {
/* Instructs the element to size itself to fit its content */
field-sizing: content;
/* Reset explicit height if inherited from global styles */
height: auto;
/* Use a fixed width to prevent jarring horizontal shifts when replacing a long placeholder */
width: 50ch; /* or 100% depending on your layout */
/* Sets a reasonable minimum height (e.g., 3 lines) for empty textareas */
min-block-size: 3lh;
/* Prevents infinite vertical growth. Once a textarea hits this */
/* height, it will stop growing and show a vertical scrollbar. */
max-block-size: 10lh;
}
select {
/* Sizes the select element to fit the active option only */
field-sizing: content;
/* Prevents the dropdown from expanding infinitely and breaking the layout */
max-inline-size: 50ch;
}
```
IMPORTANT: Explicit `width` and `height` properties override `field-sizing: content` on all form controls. If your project's global CSS sets inputs, selects, or textareas to `width: 100%`, you must explicitly reset them to `width: auto` (or `width: fit-content`) on the elements using `field-sizing: content` for auto-sizing to work. Conversely, you should explicitly set `height: auto` on textareas if a fixed height was previously set globally.
IMPORTANT: Grid and Flexbox layouts often implicitly stretch their children to fill available space. If any form control using `field-sizing: content` refuses to shrink, check its container's alignment properties and apply `align-self: start` or `justify-self: start` to the form control to override the stretching.
### Fallback strategies
Baseline status for field-sizing: Newly available. It's been Baseline since 2026-06-16.
Supported by: Chrome 123 (Mar 2024), Edge 123 (Mar 2024), Firefox 152 (Jun 2026), and Safari 26.2 (Dec 2025).
`field-sizing` should be treated as a progressive enhancement. In browsers that do not support the property, form controls gracefully degrade back to their default, fixed sizing behavior. Users will simply experience standard scrolling for overflowing content inside fixed-size inputs and textareas.
If dynamically growing fields are absolutely required for older browsers, you must use feature detection and a complex workaround. The most robust fallback for textareas uses a CSS Grid trick where a hidden pseudo-element mirrors the user's input in real-time, forcing the grid container (and the textarea inside it) to expand.
```html
<!-- The textarea is wrapped in a container that will mirror its value -->
<div class="growable-textarea" data-replicated-value="">
<textarea></textarea>
</div>
```
```javascript
// Only attach the fallback event listeners if field-sizing is unsupported
if (!CSS.supports('field-sizing', 'content')) {
document.querySelectorAll('.growable-textarea > textarea').forEach(textarea => {
textarea.addEventListener('input', () => {
textarea.parentNode.dataset.replicatedValue = textarea.value;
});
});
}
```
```css
/* Only apply the fallback if field-sizing is NOT supported */
@supports not (field-sizing: content) {
.growable-textarea {
display: grid;
}
/* The pseudo-element and textarea must share the exact same cell, font, and padding */
.growable-textarea::after,
.growable-textarea > textarea {
grid-area: 1 / 1 / 2 / 2;
font: inherit;
padding: 0.5rem;
border: 1px solid #999;
}
/* The pseudo-element renders the copied text invisibly to stretch the grid */
.growable-textarea::after {
/* The space is necessary for trailing empty lines to be rendered */
content: attr(data-replicated-value) " ";
white-space: pre-wrap;
visibility: hidden;
}
.growable-textarea > textarea {
resize: none;
overflow: hidden;
}
}
```
Given the complexity of duplicating styles and synchronizing state across DOM nodes for every form control, relying on the default fallback behavior of fixed inputs is the recommended approach for most applications unless dynamic sizing is critical to the user experience.guides/forms/forms.md
# Forms
## 1. Semantic Structure and Form Element
### Guidelines
- **DO** use the `<form>` element to wrap interactive controls for data collection.
- **DO** use `method="POST"` for sensitive data and mutations; use `method="GET"` for idempotent requests (e.g., search).
- **DO** specify the `action` attribute for the destination URL.
- **DO** specify a `name` attribute for every form control to identify data on submission.
- **DO** use semantic tags like `<button type="submit">`, `<textarea>`, and `<select>`.
- **DO** use `<fieldset>` and `<legend>` to group related controls.
- **DO** use actionable language on submit buttons (e.g., "Save changes").
- **DON'T** use `GET` for sensitive data (it exposes data in history/logs).
- **DON'T** use generic `<div>` or `<span>` for form controls.
- **DON'T** use `type="button"` for primary submission buttons.
- **DON'T** disable textarea resizing without alternate layout provisions.
### Code Example
```html
<form action="/search" method="GET">
<fieldset>
<legend>Search Preferences</legend>
<label for="q">Query:</label>
<input type="text" id="q" name="q" required>
<button type="submit">Search</button>
</fieldset>
</form>
```
### Selection Control Decision Matrix
| Options Count | Choice Type | Recommended Element | Usability & Accessibility Logic |
| :--- | :--- | :--- | :--- |
| **1–5** | Single (Exclusive) | `<input type="radio">` | **Zero-click scanning**: All choices are immediately visible. Faster scan time. |
| **6+** | Single (Exclusive) | `<select>` | **Space conservation**: Use only when vertical space is premium or the list is long. |
| **10+ / Dynamic** | Single (Exclusive) | `<input list="id">` (`<datalist>`) | **Fuzzy Search**: Prevents scrolling fatigue in massive sets (e.g., countries). |
| **Any** | Multi-select | `<input type="checkbox">` | **Standard semantics**: Native non-exclusive toggles. |
**Single-Sentence Mental Model**: "Expose mutually exclusive options as visible radio buttons when choices are fewer than six; use `<select>` only when space is constrained or the list is long."
## 2. Accessible Labeling and State
### Guidelines
- **DO** always associate `<label>` with its input using `for` and `id`.
- **DO** place labels above form controls to enable faster scanning.
- **DO** use visible labels; do not rely on `placeholder` alone.
- **DO** ensure the vertical margin between a label and its input is less than the margin between form groups (**Gestalt Proximity Rule**).
- **DO** use `aria-describedby` to link inputs with help text or error messages.
- **DO** define the `lang` attribute on `<html>` for proper device translation.
- **DO** use non-color visual cues (icons, text) to communicate state (don't rely on color alone).
- **DO** indicate clearly which fields are required.
- **DO** use `aria-live` for dynamic error announcements.
- **DON'T** use `placeholder` as a replacement for labels.
- **DON'T** use `aria-label` as the sole text description if translation is needed.
- **DON'T** disable focus outlines without providing a high-contrast alternative.
### Code Example
```html
<div class="field">
<label for="username">Username:</label>
<input type="text" id="username" name="username" aria-describedby="user-help" required>
<span id="user-help" class="hint">3-12 characters.</span>
</div>
<style>
input:focus-visible {
outline: 3px solid #0b57d0;
outline-offset: 2px;
}
</style>
```
## 3. Autofill and Input Modes
### Guidelines
- **DO** use the `autocomplete` attribute to specify expected data (e.g., `email`, `tel`, `current-password`, `new-password`).
- **DO** use `inputmode` to optimize on-screen keyboards (e.g., `inputmode="numeric"` for PINs).
- **DO** use `enterkeyhint` to set the Enter key label (e.g., `next`, `done`).
- **DO** use single-field inputs for complex numbers (credit cards, phones) to help autofill.
- **DON'T** use `type="number"` for credit cards or ZIP codes (causes UI scroll issues and removes leading zeros).
### Code Example
```html
<label for="zip">ZIP Code:</label>
<input type="text" id="zip" name="zip" autocomplete="postal-code" inputmode="numeric" pattern="\d{5}">
```
## 4. Constraints and Validation
### Guidelines
- **DO** use native constraints: `required`, `minlength`, `maxlength`, `pattern`.
- **DO** use CSS pseudo-classes `:invalid:user-invalid` for non-intrusive styling.
- **DO** use the ValidityState API (`setCustomValidity`) for custom messaging.
- **DON'T** disable submit buttons to block validation; let users submit and highlight errors. However, **DO** disable the button *after* a valid submission is clicked to prevent double-posts.
### Code Example
```html
<label for="code">Activation Code (4 digits):</label>
<input type="text" id="code" name="code" required pattern="\d{4}">
<script>
const input = document.getElementById('code');
input.addEventListener('invalid', () => {
input.setCustomValidity('Please enter exactly 4 digits.');
});
input.addEventListener('input', () => {
input.setCustomValidity('');
});
</script>
```
### Validation Event Timing Matrix
| Event Trigger | Phase | Action Allowed | UX / Accessibility Logic |
| :--- | :--- | :--- | :--- |
| **`input`** | Active Typing | **Clear** existing errors only. | **Non-intrusive**: Do not yell at the user before they finish typing. |
| **`blur` / `focusout`** | Exiting Field | **Run** check and show error. | **Contextual validation**: Validate once the user indicates they are "done" with a field. |
| **`submit`** | Final Attempt | **Block** and route focus. | **Final gatekeeper**: Intercepts bad payloads and forces screen reader focus to the summary. |
**Single-Sentence Mental Model**: "Validate on `blur` to avoid premature warnings while typing, and reset error states on `input` as soon as the user attempts a correction."
**Security vs UX Scale**: Client-side validation is for User Experience; Server-side validation is for Security. Never treat browser constraints as a data integrity defense.
## 5. Responsive Design and Typography
### Guidelines
- **DO** use single-column layouts for scanning.
- **DO** set `font-size` to at least `1rem` (16px) to prevent iOS zoom.
- **DO** expand clickable areas for mobile tap targets using padding tricks.
- **DO** ensure tap targets are at least `48px`.
- **DO** use units relative to root (`rem`) and unitless `line-height`.
- **DO** use CSS logical properties (e.g., `margin-inline-start`) for RTL support.
### Code Example
```css
.form-group {
margin-block-end: 1.5rem;
}
/* Expand clickable tap area without layout shift */
label {
display: inline-block;
padding: 10px 0;
margin: -10px 0;
}
input {
font-size: 1rem;
padding: 0.75rem;
min-height: 48px;
box-sizing: border-box;
}
@media (pointer: coarse) {
input {
min-height: 52px;
}
}
```
## 6. Styling Form Controls
### Guidelines
- **DO** use `accent-color` for quick branding of native radios/checkboxes.
- **DO** use `appearance: none` for custom dropdown arrows without breaking semantics.
- **DO** ensure inputs are clearly visible with adequate border contrast (e.g., `#ccc` or darker on white backgrounds).
- **DO** hide inputs visually using the canonical `.visually-hidden` recipe (`clip-path: inset(50%)` with 1px dimensions) — NOT `display: none`, which removes them from the accessibility tree.
### Code Example
```html
<div class="checkbox-container">
<input type="checkbox" id="sub" name="sub" class="visually-hidden">
<label for="sub" class="checkbox-label">Subscribe</label>
</div>
<style>
.visually-hidden {
position: absolute;
clip-path: inset(50%);
overflow: hidden;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
white-space: nowrap;
}
.checkbox-label::before {
content: "";
display: inline-block;
width: 1.25rem;
height: 1.25rem;
border: 2px solid #ccc;
}
input:focus-visible + .checkbox-label::before {
outline: 2px solid #0b57d0;
}
</style>
```
## 7. JavaScript and AJAX
### Guidelines
- **DO** check `KeyboardEvent.isComposing` before treating the `Enter` key as a submit action in chat or text inputs to ensure IME text composition is not interrupted. See guide `ime-safe-enter-submit` (via `npx -y modern-web-guidance@latest retrieve "ime-safe-enter-submit"`).
- **DO** prevent default navigation on form submit for AJAX (`e.preventDefault()`).
- **DO** use `ValidityState` interfaces for real-time validation checks.
- **DO** use `aria-expanded` and `aria-controls` for dynamic UI reveals.
- **DON'T** block page submission if JS fails; ensure server-side fallback.
### Code Example
```js
form.addEventListener('submit', (e) => {
e.preventDefault();
const data = new FormData(form);
// fetch('/submit', { method: 'POST', body: data });
});
```
## 8. Identity, Payments, and Advanced Security
### Guidelines
- **DO** use `autocomplete="new-password"` for sign-up and `autocomplete="current-password"` for sign-in.
- **DO** allow pasting into password fields.
- **DO** provide a toggle capability allowing users to unmask password input.
- **DO** indicate exact amounts on pay buttons (e.g., "Pay $100").
- **DO** use `autocomplete="cc-number"`, `cc-exp`, `cc-csc`.
- **DO** use HTTPS for all pages.
- **DO** implement cryptographically secure anti-CSRF tokens for mutating actions (POST/PUT/DELETE).
- **DO** sanitize user input (e.g., via DOMPurify) before injecting it into the DOM to prevent XSS.
- **DO** implement spam protection (honeypots or CAPTCHA) for open forms.
- **DON'T** utilize HTTP `GET` for endpoints executing state changes.
- **DON'T** use inline JavaScript (e.g., `onclick="..."`) directly within form markup to satisfy strict Content Security Policies (CSP).
### Code Example
```html
<form method="post">
<input type="hidden" name="csrf_token" value="secure_token_abc123">
<h1>Sign up</h1>
<div class="form-group">
<label for="name">Full name</label>
<input id="name" name="name" autocomplete="name" required pattern="[\p{L}\.\- ]+">
</div>
<div class="form-group">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<button id="toggle-password" type="button" aria-pressed="false" aria-label="Show password" aria-describedby="toggle-warning">
<img class="icon-eye" src="/icons/eye.svg" alt="" width="20" height="20">
<img class="icon-eye-off" src="/icons/eye-off.svg" alt="" width="20" height="20">
</button>
<span id="toggle-warning" class="visually-hidden">Warning: this will display your password on the screen.</span>
<input id="password" name="password" type="password" autocomplete="new-password" minlength="8" aria-describedby="password-constraints" required>
<div id="password-constraints">Eight or more characters.</div>
</div>
<button id="sign-up">Sign up</button>
</form>
```
## 9. Address Collection
### Guidelines
- **DO** use a single field for names.
- **DO** use `autocomplete="street-address"`.
- If the site has users in different countries, **DO** use the `<textarea>` element for addresses, to accommodate different address formats in different geographical regions. If the form uses separate inputs for address parts (e.g. Street, City), **DO** use `autocomplete` values `address-line1`, `address-line2`, etc.
- **DO** make postal codes optional.
- **DON'T** split name inputs into rigid variables ("First", "Last") for global audiences.
- **DON'T** enforce Latin-only characters for names and usernames.
### Code Example
```html
<!-- Accessible Address Form with Autofill -->
<form action="/save-address" method="POST">
<div class="form-group">
<label for="full-name">Full name</label>
<input type="text" id="full-name" name="full_name" maxlength="100" required autocomplete="name">
</div>
<div class="form-group">
<label for="address">Address</label>
<textarea id="address" name="address" required autocomplete="street-address" maxlength="300"></textarea>
</div>
<button type="submit">Save Address</button>
</form>
```
## 10. Usability Testing and Analytics
### Guidelines
- **DO** test forms across multiple devices, browsers, and screen sizes.
- **DO** test keyboard-only navigation (using `Tab` and `Shift+Tab`) and verify visual focus.
- **DO** emulate various impairments (visual, motor) using browser tools.
- **DO** use analytics to monitor form completion rates and bounce points.
- **DO** track discrete events (e.g., field focus, click) to find micro-friction points.
- **DON'T** rely solely on automated tools (Lighthouse) for usability; test with real users.
- **DON'T** track sensitive personal data in standard event labels.
### Code Example
```html
<form action="/submit" method="POST" id="track-form">
<label for="postal-code">ZIP or postal code</label>
<input type="text" id="postal-code" name="postal-code" autocomplete="postal-code" maxlength="20" required>
<button type="submit" id="submit-btn">Submit</button>
</form>
<script>
const trackForm = document.getElementById('track-form');
const trackBtn = document.getElementById('submit-btn');
trackBtn.addEventListener('click', () => {
console.log('Analytics Event: Submit clicked');
});
</script>
```
## 11. Multi-Page Forms
### Guidelines
- **DO** clearly display progress through a multi-page form with clear labels and progress indicators.
- **DO** allow users to navigate backwards and forwards between pages.
- **DO** use context-specific `enterkeyhint` values (e.g., `"previous"`, `"next"`) to guide navigation via on-screen keyboards.
- **DO** design layouts so that the mobile keyboard does not obscure inputs or buttons (e.g., by placing them in the upper half of the viewport when focused or using CSS scroll-padding).
### Code Example
```html
<nav aria-label="Progress">
<ol class="progress-tracker">
<li class="step-done">Step 1: Account</li>
<li class="step-active" aria-current="step">Step 2: Shipping</li>
<li class="step-todo">Step 3: Payment</li>
</ol>
</nav>
<button type="button" onclick="history.back()" enterkeyhint="previous">Previous</button>
<button type="submit" enterkeyhint="next">Next</button>
```
guides/forms/ime-safe-enter-submit.md
# IME-safe enter-to-submit
Many chat interfaces submit their message when the user presses `Enter` in a `<textarea>`.
This works for users typing with direct Latin keyboard input (e.g. English), but breaks for users typing with an Input Method Editor (IME) to compose text in languages like Japanese, Chinese, or Korean.
In these contexts, the `Enter`/`Return` key is used to confirm the current character conversion candidate. If custom JavaScript listens to `keydown` on `Enter` and submits immediately, the user's message is sent while they are still converting characters, resulting in incomplete, fragmented, or incorrect messages.
Note that the composition-active check only matters for multiline `<textarea>` fields where custom JavaScript intercepts `Enter` for submission. For single-line `<input>` fields inside a `<form>`, no explicit handling is required, as browsers natively suppress implicit submission when the `Enter` keystroke is consumed by an IME.
## Implementation strategy
For a `<textarea>` with custom enter-to-submit, check the native `isComposing` property of the `KeyboardEvent` before submitting the content. The default action of `Enter` in a `<textarea>` is to insert a newline, so you must also call `event.preventDefault()` to suppress that.
```html
<form id="chat-form">
<label for="chat-input" class="visually-hidden">Message</label>
<textarea id="chat-input" placeholder="Type a message..."></textarea>
<button type="submit" id="send-button">Send</button>
</form>
```
```js
const textarea = document.getElementById('chat-input');
const form = document.getElementById('chat-form');
textarea.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
// Prevent the default newline behavior
event.preventDefault();
// If the user is composing text, return early.
if (event.isComposing) {
return;
}
form.requestSubmit();
}
});
```
Note: Other custom submission shortcuts (such as `Cmd+Enter` or `Ctrl+Enter`) do not conflict with IME confirmation keys and do not require IME safety checks.
## Accessibility and testing
1. **Explicit submit button**: Always include a `<button type="submit">` element. Keyboard shortcuts are helpers; they must not replace native form submit paths.
2. **Accessible inputs**: Ensure all `<input>` and `<textarea>` elements are programmatically associated with a `<label>` using matching `id` and `for` attributes.
## Fallback strategies
the api.KeyboardEvent.isComposing capability has limited availability.
Supported by: Chrome 56 (Jan 2017), Edge 79 (Jan 2020), and Firefox 31 (Jul 2014).
Unsupported in: Safari.
In Safari, an event-ordering issue (WebKit bug 165004) delivers `compositionend` to script handlers before the confirming `Enter` `keydown`, even though the underlying events are dispatched in the opposite order. By the time the keydown handler runs, `event.isComposing` has already been reset to `false`, meaning the standard check alone will fail to prevent premature submission.
If you need to support cross-browser compatibility across Safari and other platforms, adopt one of the following fallback strategies:
### Strategy 1: Add a `keyCode === 229` check (recommended)
By pairing `event.isComposing` with a check for `event.keyCode === 229`, you can reliably catch Safari's out-of-order confirming `Enter` keydown. Because this is nested under the `event.key === 'Enter'` gate, it is safe from mobile virtual keyboards that might use `229` for normal character layout entry.
```js
textarea.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
// Block submission if composing natively or if keyCode is 229
if (event.isComposing || event.keyCode === 229) {
return;
}
form.requestSubmit();
}
});
```
### Strategy 2: The `event.timeStamp` window workaround (alternative)
For codebases that strictly forbid the use of deprecated APIs like `keyCode`, or if there are known issues with the `229` check in specific targeted environments, you can track the browser-reported event dispatch timestamp instead.
Because Safari dispatches the confirming `Enter` keydown event extremely close to the `compositionend` event (often within 5ms, and sometimes with the keydown timestamp being slightly *earlier* due to handler delivery order inversion), checking the time difference is highly reliable and is unlikely to trigger false-positives on mobile virtual keyboards under standard conditions.
```js
let lastCompositionEndAt = null;
textarea.addEventListener('compositionend', (event) => {
// IMPORTANT: Always use event.timeStamp, not Date.now() or performance.now().
// Handler-time measurements are vulnerable to drift when the main thread is blocked.
lastCompositionEndAt = event.timeStamp;
});
textarea.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
if (event.isComposing) {
return;
}
// Block submission if the event occurs within a 50ms window of composition ending.
// Math.abs handles Safari's inverted event delivery timing bug.
if (
lastCompositionEndAt !== null &&
Math.abs(event.timeStamp - lastCompositionEndAt) < 50
) {
return;
}
form.requestSubmit();
}
});
```
guides/forms/required-field-feedback.md
# Required Field Feedback
## The Problem
Marking required fields with an error state immediately upon page load can be confusing. Ideally, a required field should only look "invalid" if the user has attempted to fill it out and failed.
## The Solution
The `:user-invalid` pseudo-class solves this perfectly. For a required field, it will not match on page load. It will only match if:
1. The user interacts with the field (e.g., types a character and deletes it) and then leaves it (blur), leaving it empty.
2. The user attempts to submit the form while the field is empty.
### Implementation Strategy
1. **HTML Constraint**: Add the `required` attribute to your inputs.
2. **Visual Feedback**: Use `:user-invalid` to style the border red and show a "Required" helper text.
3. **Timing**: Rely on the browser's native timing for visual feedback. You don't need `onBlur` handlers to add a `touched` class anymore, though some JavaScript is still needed to sync ARIA attributes (see below).
## Implementation Guide
### 1. HTML Structure
```html
<form id="feedback-form">
<div class="field">
<label for="full-name">Full Name</label>
<input
type="text"
id="full-name"
name="full-name"
required
aria-errormessage="name-error"
>
<!-- MANDATORY: Include an icon or distinct non-color indicator alongside error text -->
<div id="name-error" class="error-msg">
<span aria-hidden="true">❌</span> This field is required.
</div>
</div>
</form>
```
### 2. CSS
```css
.error-msg {
display: none;
color: #d93025;
font-size: 0.875rem;
margin-top: 0.25rem;
}
/*
Only highlight empty required fields AFTER the user visits them.
MANDATORY: Provide multiple indicators (border shift + helper text/icon) to avoid color-only state communication.
*/
input:user-invalid {
border-color: #d93025;
background-color: #fce8e6;
}
input:user-invalid + .error-msg {
display: block;
}
/* Optional: Subtle indicator for required fields that are valid */
input:required:user-valid {
border-color: #188038;
border-width: 2px;
}
```
### 3. JavaScript State Synchronization
MANDATORY: Because `:user-invalid` is a visual state, you MUST provide a JavaScript bridge to sync `aria-invalid="true"` dynamically for assistive technologies when a user blurs an invalid field or attempts submission.
```javascript
const form = document.getElementById('feedback-form');
const syncAriaInvalid = (input) => {
if (!input.checkValidity()) {
input.setAttribute('aria-invalid', 'true');
} else {
input.removeAttribute('aria-invalid');
}
};
// Sync on blur when a user finishes interacting
form.addEventListener('blur', (e) => {
if (e.target.matches('input[required]')) {
syncAriaInvalid(e.target);
}
}, true);
// Sync all required fields when submission is attempted
form.addEventListener('submit', () => {
form.querySelectorAll('input[required]').forEach(syncAriaInvalid);
});
// Remove error state immediately upon correction
form.addEventListener('input', (e) => {
if (e.target.matches('input[required]') && e.target.checkValidity()) {
e.target.removeAttribute('aria-invalid');
}
});
```
## Fallbacking & Browser Support
### Fallbacks & browser support for :user-valid and :user-invalid
Baseline status for :user-valid and :user-invalid: Widely available. It's been Baseline since 2023-11-02.
Supported by: Chrome 119 (Oct 2023), Edge 119 (Nov 2023), Firefox 88 (Apr 2021), and Safari 16.5 (May 2023).
### CSS for Fallback
```css
input:user-invalid,
input.user-invalid-fallback {
border-color: #d93025;
background-color: #fce8e6;
}
input:user-invalid + .error-msg,
input.user-invalid-fallback + .error-msg {
display: block;
}
```
### JavaScript Fallback
Use a reusable utility that tracks interaction state using a `WeakMap`. This avoids polluting the DOM with "dirty" classes or data attributes.
```javascript
const UserInvalidFallback = (() => {
const dirtyState = new WeakMap();
const updateState = (input) => {
const isValid = input.checkValidity();
// Update both visual and ARIA state
input.classList.toggle('user-invalid-fallback', !isValid);
input.classList.toggle('user-valid-fallback', isValid);
if (!isValid) {
input.setAttribute('aria-invalid', 'true');
} else {
input.removeAttribute('aria-invalid');
}
};
const handleEvent = (event) => {
const input = event.target;
if (event.type === 'reset') {
const controls = input.elements || [];
for (const control of controls) {
dirtyState.delete(control);
control.classList.remove('user-invalid-fallback');
control.classList.remove('user-valid-fallback');
control.removeAttribute('aria-invalid');
}
return;
}
if (!input.checkValidity) return;
if (event.type === 'input' || event.type === 'change') {
const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
state.hasInteracted = true;
dirtyState.set(input, state);
if (state.hasBlurred) {
updateState(input);
}
} else if (event.type === 'blur') {
const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
state.hasBlurred = true;
dirtyState.set(input, state);
if (state.hasInteracted) {
updateState(input);
}
}
};
const init = (root = document) => {
if (CSS.supports('selector(:user-invalid)')) return;
root.addEventListener('blur', handleEvent, true); // Capture phase
root.addEventListener('input', handleEvent);
root.addEventListener('change', handleEvent);
root.addEventListener('reset', handleEvent, true); // Capture resets
};
return { init };
})();
// Initialize for a specific form
const form = document.querySelector('#demo-form');
UserInvalidFallback.init(form);
```
## Other Considerations
1. **Asterisks**: It is still best practice to indicate required fields visually (e.g., with an asterisk `*`) in the label, so users know what to expect *before* they interact.
2. **Submit Buttons**: Unlike `disabled` buttons, keep your submit button enabled. If the user clicks it, the browser will automatically trigger `:user-invalid` on all empty required fields and focus the first one. This is excellent for accessibility and UX.
3. **Accessibility**: Native `:user-invalid` does not automatically sync with ARIA attributes. Add the following JavaScript to keep `aria-invalid` in sync with the visual state:
```javascript
// Sync aria-invalid with the CSS :user-invalid state
const syncAria = (el) => {
el.setAttribute?.('aria-invalid', el.matches(':user-invalid') ? 'true' : 'false');
};
// Update on blur (to show error) and input (to clear it)
document.addEventListener('blur', (e) => syncAria(e.target), true);
document.addEventListener('input', (e) => {
if (e.target.hasAttribute('aria-invalid')) syncAria(e.target);
});
```
guides/forms/rich-media-picker.md
# Rich Media Picker (Customizable Select)
The native `<select>` element was historically difficult to style and could only contain plain text options. The `appearance: base-select` property offers a declarative, CSS-only way to opt into a customizable state for the `<select>` element. This allows developers to include rich HTML content—such as images, SVGs, and complex layouts—inside `<option>` elements, while retaining native keyboard accessibility and form integration. Use this pattern to replace heavy, custom-built select components with standard, native elements.
## How to Implement
To implement a rich media picker using the Customizable Select API:
1. **Opt-in to base styles**: Apply `appearance: base-select` to both the `<select>` element and its internal picker using the `::picker(select)` pseudo-element. This changes the browser's HTML parser for the contents inside the `<select>`.
2. **Define the Button Content**: Use standard `<button>` and `<selectedcontent>` elements inside the `<select>` to define what is shown when the picker is closed. The `<selectedcontent>` element automatically mirrors the content of the selected option. This is required if you want to display the currently selected rich content in the button.
3. **Use Rich Content inside Options**: You can now place images, SVGs, and other HTML tags inside `<option>` tags. Prior to this API, the browser would strip tags from `<option>` tags and render only plain text.
4. **Style the Popover**: Style the dropped-down options list by targeting the `::picker(select)` pseudo-element. It renders in the top layer, meaning you don't need to fight with `z-index`. Options are positioned using the Anchor Positioning API natively.
## Example Code: Rich Role Picker
```html
<label for="role-picker">Select your role</label>
<select class="custom-select" name="role" id="role-picker">
<button>
<selectedcontent></selectedcontent> <!-- Mirrors the selected option's content automatically so you do not need JS to update the button -->
</button>
<!-- Define concise aria-label values on options whose mirrored rich content would otherwise read awkwardly as a concatenated string -->
<option value="frontend" aria-label="Frontend Developer">
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
</svg>
<div class="option-text">
<span class="option-title">Frontend Developer</span>
<span class="option-desc">React, Vue, CSS</span>
</div>
</option>
<option value="backend" aria-label="Backend Developer">
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="2" y="12" width="20" height="14" rx="2" ry="2"></rect>
</svg>
<div class="option-text">
<span class="option-title">Backend Developer</span>
<span class="option-desc">Node.js, Python</span>
</div>
</option>
</select>
```
```css
select.custom-select,
select.custom-select::picker(select) {
appearance: base-select; /* MUST opt-in both the select and its picker to enable the customizable state, otherwise browser standard rendering applies */
}
select.custom-select {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px;
background-color: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
color: #f1f5f9;
cursor: pointer;
}
select.custom-select::picker(select) {
background-color: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
padding: 8px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
width: anchor-size(width); /* Uses Anchor Positioning API to keep the dropdown precisely aligned to the button trigger width */
}
select.custom-select option {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border-radius: 4px;
cursor: pointer;
}
select.custom-select option:hover {
background-color: #1e293b;
}
/* Remove standard OS checkmark for base-select */
select.custom-select option::before {
display: none;
}
/* MANDATORY: Provide multiple visual indicators (e.g., prominent background color and bold title font) to communicate the checked state cleanly */
select.custom-select option:checked {
background-color: #3b82f6;
color: #ffffff;
}
select.custom-select option:checked .option-title {
font-weight: 700;
}
```
## Strategic Implementation & Best Practices
- **DO** use `appearance: base-select` when you need complex options layouts (icons, descriptions, images) that was previously only possible with heavy JavaScript UI frameworks.
- **DO NOT** use ad-hoc elements if you notice performance lags; the browser handles native keyboard focus natively.
- **DO** account for top-layer rendering. The picker renders in the top-layer, meaning it overrides relative `z-index` of page content.
- **DO** hide secondary details (like descriptions) in the button state if they take too much space, by styling `.custom-select selectedcontent .option-desc { display: none; }`.
- **DO** test layout behavior. Setting `appearance: base-select` removes the default browser behavior of sizing the select based on its longest option width. You may need to set a fixed width or use flex/grid constraints to prevent layout shifts.
- **DO** ensure your `<select>` has a `name` attribute and an associated `<label>`. This ensures that even with a custom UI, the component remains accessible to screen readers and works correctly with standard form submissions.
## Fallback Strategies
### Fallbacks & browser support for Customizable <select>
Customizable <select> has limited availability.
Supported by: Chrome 135 (Apr 2025) and Edge 135 (Apr 2025).
Unsupported in: Firefox and Safari.
For browsers that do not yet support `appearance: base-select`, the `<select>` element degrades gracefully to a standard operating system dropdown.
- **Non-Text Content Ignored**: Older browsers strip HTML tags (like `<svg>` or `<div>`) inside `<option>` tags and render only the text nodes. Ensure the text content of the `<option>` is readable and meaningful on its own.
- **HTML Structure Handling**: Standard parsers may ignore the `<button>` and `<selectedcontent>` tags inside `<select>` or treat them as invalid. No heavy JavaScript polyfills are strictly required for progressive enhancement if you view standard text as a readable fallback.
```javascript
document.addEventListener("DOMContentLoaded", () => {
// Check if browser supports base-select value
if (!CSS.supports("appearance", "base-select")) {
// Custom select overrides are not supported natively.
}
});
```
guides/forms/select-menu-interaction.md
# Select Menu Interaction
## The Problem
For mandatory dropdowns (e.g., "Choose a Country"), standard validation flags the field as invalid immediately if the default option has an empty value. This can create visual noise. We want to show the error only if the user opens the menu and closes it without choosing an option, or attempts to submit the form.
## The Solution
The `:user-invalid` pseudo-class works seamlessly with `<select>` elements. It respects the user's interaction flow: simply loading the page or focusing/blurring without making a change doesn't count as an interaction, so the field stays neutral until they actively attempt a selection.
### Implementation Strategy
1. **HTML Constraint**: Use a `<select>` with `required`. The first option should have `value=""` and ideally be disabled/hidden to force a valid choice.
2. **Visual Feedback**: Use `:user-invalid` to style the select box border.
3. **Timing**: The browser considers the field "interacted" if the user changes the value (even back to the default invalid state) before they blur the control, or upon form submission.
## Implementation Guide
### 1. HTML Structure
The "placeholder" option is key here.
```html
<form>
<div class="field">
<label for="country">Country</label>
<select
id="country"
name="country"
required
aria-errormessage="country-error"
>
<option value="" disabled selected>Select a country...</option>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="uk">United Kingdom</option>
</select>
<div id="country-error" class="error-msg">
Please select a country.
</div>
</div>
</form>
```
### 2. CSS
```css
.error-msg {
display: none;
color: #d93025;
font-size: 0.875rem;
margin-top: 0.25rem;
}
/*
Only show error after the user visits the select menu.
*/
select:user-invalid {
border-color: #d93025;
background-color: #fce8e6;
}
select:user-invalid + .error-msg {
display: block;
}
select:user-valid {
border-color: #188038;
}
```
## Fallbacking & Browser Support
### Fallbacks & browser support for :user-valid and :user-invalid
Baseline status for :user-valid and :user-invalid: Widely available. It's been Baseline since 2023-11-02.
Supported by: Chrome 119 (Oct 2023), Edge 119 (Nov 2023), Firefox 88 (Apr 2021), and Safari 16.5 (May 2023).
### CSS for Fallback
```css
input:user-invalid,
input.user-invalid-fallback {
border-color: #d93025;
background-color: #fce8e6;
}
input:user-invalid + .error-msg,
input.user-invalid-fallback + .error-msg {
display: block;
}
```
### JavaScript Fallback
Use a reusable utility that tracks interaction state using a `WeakMap`. This avoids polluting the DOM with "dirty" classes or data attributes.
```javascript
const UserInvalidFallback = (() => {
const dirtyState = new WeakMap();
const updateState = (input) => {
const isValid = input.checkValidity();
// Update both visual and ARIA state
input.classList.toggle('user-invalid-fallback', !isValid);
input.classList.toggle('user-valid-fallback', isValid);
if (!isValid) {
input.setAttribute('aria-invalid', 'true');
} else {
input.removeAttribute('aria-invalid');
}
};
const handleEvent = (event) => {
const input = event.target;
if (event.type === 'reset') {
const controls = input.elements || [];
for (const control of controls) {
dirtyState.delete(control);
control.classList.remove('user-invalid-fallback');
control.classList.remove('user-valid-fallback');
control.removeAttribute('aria-invalid');
}
return;
}
if (!input.checkValidity) return;
if (event.type === 'input' || event.type === 'change') {
const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
state.hasInteracted = true;
dirtyState.set(input, state);
if (state.hasBlurred) {
updateState(input);
}
} else if (event.type === 'blur') {
const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
state.hasBlurred = true;
dirtyState.set(input, state);
if (state.hasInteracted) {
updateState(input);
}
}
};
const init = (root = document) => {
if (CSS.supports('selector(:user-invalid)')) return;
root.addEventListener('blur', handleEvent, true); // Capture phase
root.addEventListener('input', handleEvent);
root.addEventListener('change', handleEvent);
root.addEventListener('reset', handleEvent, true); // Capture resets
};
return { init };
})();
// Initialize for a specific form
const form = document.querySelector('#demo-form');
UserInvalidFallback.init(form);
```
## Other Considerations
1. **Mobile behavior**: On mobile devices, "blur" might happen differently depending on the OS picker. Testing on actual devices is recommended.
2. **Accessibility**: Native `:user-invalid` does not automatically sync with ARIA attributes. Add the following JavaScript to keep `aria-invalid` in sync with the visual state:
```javascript
// Sync aria-invalid with the CSS :user-invalid state
const syncAria = (el) => {
el.setAttribute?.('aria-invalid', el.matches(':user-invalid') ? 'true' : 'false');
};
// Update on blur (to show error) and input (to clear it)
document.addEventListener('blur', (e) => syncAria(e.target), true);
document.addEventListener('input', (e) => {
if (e.target.hasAttribute('aria-invalid')) syncAria(e.target);
});
```
guides/forms/validate-input-after-interaction.md
# Validate Input After Interaction
## The Problem
Displaying validation errors the moment a user focuses on a field and starts typing is premature and distracting. For example, as a user types an email address (e.g., "user@gm") or a password with complex requirements, the field is technically invalid until completion. Standard `:invalid` styling results in an error state appearing immediately, frustrating the user.
## The Solution
The `:user-invalid` pseudo-class allows you to defer the error state until the user has "committed" to a value (by blurring the field) or attempted to submit the form. This ensures validation feedback is provided only after the user has finished interacting with the field.
### Implementation Strategy
1. **HTML Constraint**: DO use standard HTML5 attributes like `type="email"`, `pattern`, and `required` to trigger the browser's built-in validation logic.
2. **Visual Feedback**: DO use `:user-invalid` to apply error styling only after interaction.
3. **Positive Reinforcement**: DO optionally use `:user-valid` to give a green "success" indicator once the requirements are met.
4. **Graceful Recovery**: As soon as the user corrects the input to a valid format, `:user-invalid` stops matching, removing the error state immediately.
## Implementation Guide
### Use Case 1: Email Validation
MANDATORY: Rely on standard HTML5 attributes for email fields. The error message is hidden by default and only revealed when the browser determines the user has left the field in an invalid state.
```html
<form>
<div class="field">
<label for="email">Email Address</label>
<!-- MANDATORY: Place format hints above the input so autocomplete popovers don't cover them during editing -->
<span id="email-hint" class="hint">Format: you@example.com</span>
<!-- DO: Use standard HTML validation attributes like type="email" and required -->
<input
type="email"
id="email"
name="email"
required
autocomplete="email"
aria-describedby="email-hint"
aria-errormessage="email-error"
>
<div id="email-error" class="error-msg">
<span aria-hidden="true">❌</span> Please enter a valid email address.
</div>
</div>
</form>
```
```css
.hint {
display: block;
color: #5f6368;
font-size: 0.85rem;
margin-bottom: 0.25rem;
}
.error-msg {
display: none;
color: #d93025;
font-size: 0.875rem;
margin-top: 0.25rem;
}
/*
DO: Only show error styles after user interaction.
Use multiple indicators (border/background shift + icon/text) to avoid color-only states.
*/
input:user-invalid {
border-color: #d93025;
background-color: #fce8e6;
}
/* DO: Reveal the error message using the adjacent sibling selector */
input:user-invalid + .error-msg {
display: block;
}
/* DO: Provide a clear success indication on :user-valid */
input:user-valid {
border-color: #188038;
}
```
### Use Case 2: Password Complexity
MANDATORY: Define the complexity rule using a Regex Lookahead pattern in the `pattern` attribute. The rules list is shown above the input to guide the user, and highlighted if there's an error.
```html
<form>
<div class="field">
<label for="password">New Password</label>
<!-- MANDATORY: Place hints and rules above the input so mobile keyboards do not obscure them -->
<ul id="password-rules" class="rules-list">
<li>At least 8 characters</li>
<li>One uppercase letter</li>
<li>One number</li>
<li>One special character</li>
</ul>
<!-- DO: Use pattern and minlength for complex password validation
DO: Match all constraints with lookaheads via pattern attribute
-->
<input
type="password"
id="password"
autocomplete="new-password"
required
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\W_]).{8,}"
minlength="8"
aria-describedby="password-rules"
>
</div>
</form>
```
```css
/* DO: State the default styling as neutral */
.rules-list {
color: #5f6368;
margin-bottom: 0.5rem;
}
/* DO: Show invalid state (After interaction): Error */
input:user-invalid {
border-color: #d93025;
background-color: #fce8e6;
}
/* DO: Highlight rules list when error is shown using the modern :has() selector */
.field:has(input:user-invalid) .rules-list {
color: #d93025;
font-weight: 600;
}
/* DO: Add success indications for :user-valid state */
input:user-valid {
border-color: #188038;
}
/* DO: Hide rules once satisfied */
.field:has(input:user-valid) .rules-list {
display: none;
}
```
## Fallbacking & Browser Support
### Fallbacks & browser support for :user-valid and :user-invalid
Baseline status for :user-valid and :user-invalid: Widely available. It's been Baseline since 2023-11-02.
Supported by: Chrome 119 (Oct 2023), Edge 119 (Nov 2023), Firefox 88 (Apr 2021), and Safari 16.5 (May 2023).
### CSS for Fallback
```css
input:user-invalid,
input.user-invalid-fallback {
border-color: #d93025;
background-color: #fce8e6;
}
input:user-invalid + .error-msg,
input.user-invalid-fallback + .error-msg {
display: block;
}
```
### JavaScript Fallback
Use a reusable utility that tracks interaction state using a `WeakMap`. This avoids polluting the DOM with "dirty" classes or data attributes.
```javascript
const UserInvalidFallback = (() => {
const dirtyState = new WeakMap();
const updateState = (input) => {
const isValid = input.checkValidity();
// Update both visual and ARIA state
input.classList.toggle('user-invalid-fallback', !isValid);
input.classList.toggle('user-valid-fallback', isValid);
if (!isValid) {
input.setAttribute('aria-invalid', 'true');
} else {
input.removeAttribute('aria-invalid');
}
};
const handleEvent = (event) => {
const input = event.target;
if (event.type === 'reset') {
const controls = input.elements || [];
for (const control of controls) {
dirtyState.delete(control);
control.classList.remove('user-invalid-fallback');
control.classList.remove('user-valid-fallback');
control.removeAttribute('aria-invalid');
}
return;
}
if (!input.checkValidity) return;
if (event.type === 'input' || event.type === 'change') {
const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
state.hasInteracted = true;
dirtyState.set(input, state);
if (state.hasBlurred) {
updateState(input);
}
} else if (event.type === 'blur') {
const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
state.hasBlurred = true;
dirtyState.set(input, state);
if (state.hasInteracted) {
updateState(input);
}
}
};
const init = (root = document) => {
if (CSS.supports('selector(:user-invalid)')) return;
root.addEventListener('blur', handleEvent, true); // Capture phase
root.addEventListener('input', handleEvent);
root.addEventListener('change', handleEvent);
root.addEventListener('reset', handleEvent, true); // Capture resets
};
return { init };
})();
// Initialize for a specific form
const form = document.querySelector('#demo-form');
UserInvalidFallback.init(form);
```
## Other Considerations
1. **Accessibility**:
* MANDATORY: Use `aria-describedby` to link the rules list to the input.
* DO NOT: Hide rules lists entirely until the input is valid; users need to know what to type!
2. **Pattern Attribute Limits**: MANDATORY: The `pattern` attribute performs a full match (implied `^...$`). Ensure your password regex accounts for the entire string.
3. **Validation Strictness**: DO note that the browser's default `type="email"` validation is quite permissive (e.g., `user@localserver` might pass). If you need stricter validation, you may need to use a more robust validation library or a custom validation function alongside `type="email"`.
4. **Focus Management**: MANDATORY: If a user submits the form with an invalid field, the browser will automatically focus the first invalid field. Your `:user-invalid` styles will apply immediately because a submission attempt counts as an interaction.
5. **Consistent ARIA Experience**: Native `:user-invalid` does not automatically sync with ARIA attributes. Add the following JavaScript to keep `aria-invalid` in sync with the visual state:
```javascript
// Sync aria-invalid with the CSS :user-invalid state
const syncAria = (el) => {
el.setAttribute?.('aria-invalid', el.matches(':user-invalid') ? 'true' : 'false');
};
// Update on blur (to show error) and input (to clear it)
document.addEventListener('blur', (e) => syncAria(e.target), true);
document.addEventListener('input', (e) => {
if (e.target.hasAttribute('aria-invalid')) syncAria(e.target);
});
```
guides/html/html.md
# HTML
## Table of Contents
1. Fundamental Semantics and Validation
2. Content Grouping and Attribution
3. Resource Prioritization and Performance
4. Native Overlays: Dialogs and Popovers
5. Disclosures: Details and Summary
6. Focus Boundaries and Visibility
7. HTML APIs and Forms Grouping
8. Native Media Elements
9. Dynamic Styles and Interactivity
## 1. Fundamental Semantics and Validation
### Guidelines
- **DO** use the standard HTML5 doctype `<!DOCTYPE html>` to prevent quirky rendering modes.
- **DO** set the `lang` attribute on the `<html>` element for screen reader pronunciation and translation tools.
- **DO** use the `<meta name="viewport">` element with the `content` attribute set to `"width=device-width, initial-scale=1.0"` to ensure page responsiveness.
- **DO** use a single `<h1>` per page/view representing the main topic. Exceptions can be made for modal dialogs, which can also use a single `<h1>`.
- **DO** maintain a sequential, non-skipping heading hierarchy (`<h2>` to `<h3>`, but not `<h2>` to `<h4>`).
- **DO** use semantic landmarks (`<header>`, `<nav>`, `<main>`, `<aside>`, `<footer>`) to create regional navigation for assistive technologies.
- **DO** use `<search>` to enclose search and filtering mechanisms (eliminates the need for `role="search"`).
- **DO** use `<button>` for triggered actions (JS, Modals, Forms) and `<a>` strictly for URL navigation. Set `type="button"` for non-submit buttons in forms to prevent unintended submission.
- **DO** use `<ul>`, `<ol>`, and `<dl>` elements for list content.
- **DO** ensure that all interactive elements like links and buttons have accessible names.
- **DO** hide purely decorative SVG images from assistive technology using `aria-hidden="true"`. If using a decorative `<img>`, always include an empty `alt` attribute (e.g. `alt=""`).
- **DO** ensure that informative SVGs like logos, data visualizations, or icon buttons have a proper accessible name.
- **DON'T** use generic `<div>` or `<span>` when semantic elements exist, for instance for interactive elements, headings, or independently reusable self-contained content.
- **DON'T** use boolean attributes with redundant values (e.g., use `disabled`, not `disabled="disabled"`).
- **DON'T** use generic elements with added ARIA roles or states when native elements with built-in semantics and behavior exist.
- **DON'T** change the native semantics of elements with ARIA unless it is a critical requirement.
- **DON'T** use `role="presentation"` or `aria-hidden="true"` on focusable elements or their parents and ancestors.
- **DON'T** disable page zooming capabilities.
### Code Example
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard | Platform</title>
</head>
<body>
<header>
<nav>
<ul>
<li><a href="#">About</a></li>
<li><a href="#">Contact</a></li>
</ul>
</nav>
</header>
<main>
<h1>Analytics</h1>
<search>
<form action="/filter" method="GET">
<label for="search-input">Scan items:</label>
<input type="search" id="search-input" name="q">
<button type="submit">Search</button>
</form>
</search>
<article>
<h2>First post</h2>
</article>
</main>
</body>
</html>
```
## 2. Content Grouping and Attribution
### Guidelines
- **DO** use `<blockquote>` for extended quotations from another source, and use the `cite` attribute to provide a machine-readable URL for that source.
- **DO** use `<figure>` to group self-contained content (images, code snippets, or quotes) that is referenced from the main flow but could be moved to an appendix or sidebar without affecting the document's meaning.
- **DO** use `<figcaption>` as the first or last child of a `<figure>` to provide a human-readable caption or attribution.
- **DO** use the `<cite>` element inside a caption or attribution to identify the **title** of a work (e.g., a book or website name), not the author's name.
- **DO** use the `<code>` element for short fragments of computer code (e.g., variable names, file paths, or inline snippets).
- **DO** wrap `<code>` inside a `<pre>` element when displaying blocks of code to preserve whitespace and line breaks.
- **DO** ensure that code blocks are accessible by adding `tabindex="0"` to the `<pre>` element if it becomes scrollable, allowing keyboard users to reach the content.
- **DON'T** use `<blockquote>` for purely visual indentation of non-quoted text.
- **DON'T** use `<figure>` for every single image; use it only when a caption is required or when the content is a distinct, referenced unit.
- **DON'T** use `<pre>` without `<code>` for code blocks; `<pre>` alone only preserves formatting but doesn't convey that the content is a computer language.
### Code Example
```html
<!-- Quote with attribution using Figure -->
<figure>
<blockquote cite="https://html.spec.whatwg.org/">
<p>The figure element represents some flow content, optionally with a caption, that is self-contained and is typically referenced as a single unit from the main flow of the document.</p>
</blockquote>
<figcaption>
Definition of the <figure> element from the <cite>HTML Living Standard</cite>
</figcaption>
</figure>
<!-- Image with caption -->
<figure>
<img
src="architecture-diagram.webp"
alt="Diagram showing the flow between Client, API Gateway, and Microservices"
width="800"
height="450"
loading="lazy"
>
<figcaption>Figure 1: High-level system architecture overview.</figcaption>
</figure>
<!-- Code block with accessibility and language hint -->
<figure>
<figcaption>Example configuration:</figcaption>
<pre tabindex="0"><code class="language-json">
{
"name": "gemini-cli",
"version": "1.0.0",
"private": true
}
</code></pre>
</figure>
<!-- Inline code -->
<p>To initialize the project, run the <code>npm install</code> command.</p>
```
## 3. Resource Prioritization and Performance
### Guidelines
- **DO** use `fetchpriority="high"` for the Largest Contentful Paint (LCP) element (e.g., hero image) to elevate network priority.
- **DO** use `<link rel="preload" as="image">` with `fetchpriority="high"` for LCP background images defined in CSS.
- **DO** apply `loading="lazy"` to off-screen images and iframes to defer bandwidth.
- **DO** specify `width` and `height` on all `<img>` tags to preserve aspect ratio and prevent Layout Shifts (CLS).
- **DO** use the `srcset` attribute on `<img>`s for adding multiple versions of the same image at different sizes.
- **DO** use the `<picture>` element with a fallback `<img>` for more fine-grained image control like switching between image formats, image sizes, and cropping images at different device sizes.
- **DON'T** apply `loading="lazy"` to above-the-fold or hero images. This delays LCP.
- **DON'T** overuse `fetchpriority="high"`; prioritization is a zero-sum mechanism. Use `fetchpriority="low"` to demote non-critical trackers or later carousel items.
### Code Example
```html
<!-- High-priority hero image with responsive sizes -->
<img
src="hero-large.webp"
srcset="hero-small.webp 480w, hero-medium.webp 800w, hero-large.webp 1200w"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 70vw"
alt="Main product view"
fetchpriority="high"
width="1200"
height="600"
>
<!-- Art direction and format switching with <picture> -->
<picture>
<!-- Mobile Art Direction: Different aspect ratio (square) and format (AVIF) -->
<source
media="(max-width: 600px)"
srcset="hero-mobile.avif 1x, hero-mobile-2x.avif 2x"
type="image/avif"
width="600"
height="600"
>
<source
media="(max-width: 600px)"
srcset="hero-mobile.webp 1x, hero-mobile-2x.webp 2x"
width="600"
height="600"
>
<!-- Desktop: Modern format for primary layout -->
<source srcset="hero-desktop.avif" type="image/avif">
<!-- Fallback img defines the default aspect ratio (2:1) -->
<img
src="hero-desktop.webp"
alt="Platform dashboard overview"
width="1200"
height="600"
loading="lazy"
>
</picture>
<!-- Low-priority decorative footer image -->
<img
src="footer-art.png"
alt=""
loading="lazy"
width="200"
height="100"
>
```
## 4. Native Overlays: Dialogs and Popovers
### Guidelines
See `declarative-dialog-popover-control` (via `npx -y modern-web-guidance@latest retrieve "declarative-dialog-popover-control"`) for more info on fallback strategies for using the Popover API in a cross-browser way.
- **DO** use `<dialog>` for modal overlays (requires JS `.showModal()`) to automatically trap focus, dim backgrounds, and support dismissing via `Esc`. Use the `closedby="any"` attribute to enable native "light-dismiss" (closing on backdrop click) without custom JavaScript.
- **DO** utilize the Popover API (`popover` attribute) for non-modal UI (menus, tooltips) that do not require focus traps.
- **DO** use `::backdrop` to style modal backgrounds.
- **DO** use `<form method="dialog">` to dismiss dialogs without manual JS handlers. Combined button `formmethod="dialog"` yields the button's value to the dialog `.returnValue`.
- **DON'T** use `show()` for modals where keyboard traps are expected (use `showModal()`).
- **DON'T** call `showModal()` on elements possessing a `popover` attribute (they are mutually exclusive programmatic states). However, `<dialog popover="auto">` is a valid declarative architecture to combine dialog semantics with light-dismiss mechanics.
### Code Example
```html
<!-- Popover (No JS required for toggle) -->
<button popovertarget="help-menu">Info</button>
<div id="help-menu" popover="auto">
<p>Standard help text.</p>
</div>
<!-- Modal Dialog with Form-based closing -->
<button id="show-dialog">Open dialog</button>
<dialog id="fav-modal">
<!-- method="dialog" closes the dialog natively and sets the returnValue -->
<form method="dialog">
<p>Confirm action?</p>
<button value="cancel">Cancel</button>
<button value="confirm">Confirm</button>
</form>
</dialog>
<script>
const dialog = document.getElementById("fav-modal");
const openModal = document.getElementById("show-dialog");
// Show modal dialog
openModal.addEventListener('click', () => dialog.showModal());
// Listen for the 'close' event to retrieve the user's choice (returnValue)
dialog.addEventListener('close', () => {
console.log(dialog.returnValue); // "confirm" or "cancel"
});
</script>
```
### Native UI Overlay & Disclosure Matrix
| Feature | Modality | Focus | Dismiss Mechanism | Use Case |
| :--- | :--- | :--- | :--- | :--- |
| **`<dialog>`** | Modal / Non-modal | Automatic trap (Modal) | Esc / Form / `closedby` | Critical Actions, Settings |
| **`[popover]`** | Non-modal | Standard Tab flow | Light-dismiss (Click outside) | Menus, Tooltips, Toasts |
| **`<details>`** | Inline Disclosure | Standard Tab flow | Toggle summary | Accordions, FAQs |
**Heuristic Rule**: Use `<dialog>` for interruptions requiring user action, `popover` for transient info, and `<details>` for inline content expansion.
## 5. Disclosures: Details and Summary
### Guidelines
- **DO** use `<details>` and `<summary>` for native accordions or revealable content without JS.
- **DO** place `<summary>` as the *first* child of `<details>`.
- If headings must be used within a `<summary>`, consider if the heading is essential for understanding or navigating the document structure. If it is, use a more robust disclosure approach that allows wrapping the disclosure trigger with the heading (e.g. `<h2><button type="button" aria-expanded="false" aria-controls="significant-section-content">Significant section</button></h2>`). This ensures the heading semantics aren’t lost, and the button and its state are announced.
- **DO** use `details[open]` attribute for styling expanded states.
- **DO** use `details::details-content` for styling the contents of the `<details>` element.
- **DO** use the `name` attribute on multiple `<details>` elements to create exclusive accordions (opening one closes others).
- **DON'T** nest other interactive elements (links, buttons) directly inside `<summary>` text as it acts as a button and breaks focus.
- **DON'T** hide visible triangles via `list-style: none` without providing explicit directional cues (via `::before`/`::after` pseudo-elements).
- **DON'T** use the `title` attribute to create tooltip effects.
### Code Example
```html
<!-- Exclusive Accordion Set -->
<details name="faq">
<summary>Item 1</summary>
<p>Contents...</p>
</details>
<details name="faq">
<summary>Item 2</summary>
<p>Contents...</p>
</details>
```
## 6. Focus Boundaries and Visibility
### Guidelines
- **DO** use the global `inert` attribute for entire hidden sections (off-screen menus, background while custom modal is open) to remove them from tab flows and accessibility trees.
- **DO** pair `[inert]` with CSS (`opacity: 0.5`) to visually signify inactivity.
- **DO** rely on natural DOM order for sequential navigation.
- **DON'T** use positive `tabindex` values (e.g., `1`, `2`). Use `0` to add element to tab flow, or `-1` for JS program focus.
- **DON'T** alter focus flow using CSS properties (`flex-flow: row-reverse`, `order`) without aligning the DOM structure.
- **DON'T** use `node.focus({ preventScroll: true })` without usability validation; it can hide the focused element off-screen.
### Code Example
```html
<!-- De-tabbing a background app shell while custom drawer is open -->
<main id="app-shell" inert>
<a href="/">Dashboard</a>
</main>
<aside id="drawer">
<button>Close</button>
</aside>
```
```css
[inert], [inert] * {
opacity: 0.5;
cursor: default;
user-select: none;
}
```
## 7. HTML APIs and Forms Grouping
### Guidelines
See `forms` (via `npx -y modern-web-guidance@latest retrieve "forms"`) for more details on creating modern web forms.
- **DO** utilize the `form="form-id"` attribute to decouple inputs from the physical `<form>` tree.
- **DO** use `<datalist>` coupled with `<input list="id">` for lightweight auto-suggestions (note: visually unstylable and has screen-reader quirks).
- **DON'T** use `autocomplete="off"` on credential, address, payment, or contact fields. Browsers and password managers ignore it there by design. Use a specific token instead (`autocomplete="email"`, `"street-address"`, `"cc-number"`, etc.).
- **DON'T** use `autocomplete="off"` unless handling highly sensitive tracking tokens (violates standard password manager overrides). Use standard inputs `type="email"`, `type="tel"`.
- **DO** distinguish `autocomplete="current-password"` (sign-in) from `autocomplete="new-password"` (registration / password change) so password managers offer the right action.
- **DO** match `autocomplete` tokens with appropriate `inputmode` and `type` (`type="email"` + `inputmode="email"` + `autocomplete="email"`). They control different things — keyboard, validation, and autofill respectively — and reinforce each other.
### Code Example
```html
<form>
<fieldset>
<legend>Address Information</legend>
<label for="city">City:</label>
<input type="text" id="city" list="cities" autocomplete="address-level2">
<datalist id="cities">
<option value="New York">
<option value="London">
</datalist>
</fieldset>
</form>
```
## 8. Native Media Elements
### Guidelines
- **DO** set `width` and `height` to prevent layout shifts (CLS) on `<video>` elements.
- **DO** provide a `poster` image fallback for videos.
- **DO** include subtitles and captions with `<track>`.
- **DO** ensure background videos are `muted`, provide users with full control over playback, and use `role="none"` or `aria-hidden="true"`. The `controls` attribute must also be omitted to make sure the video is not focusable.
- **DON'T** rely on JS for basic video controls if native `controls` attribute is sufficient.
- **DON'T** apply `role="none"` or `aria-hidden="true"` to focusable elements (such as embedded interactive `<iframe>` components). Hiding elements from the assistive technology tree while leaving them accessible to sequential keyboard navigation violates core accessibility heuristics. The background video exception holds solely because omitting the `controls` attribute renders the `<video>` element fully non-focusable.
### Code Example
```html
<video
controls
width="800"
height="450"
poster="poster.webp"
>
<source src="intro.webm" type="video/webm">
<source src="intro.mp4" type="video/mp4">
<track src="caps.vtt" kind="captions" srclang="en" label="English">
</video>
```
## 9. Dynamic Styles and Interactivity
### Guidelines
- **DO** use the `style` attribute to pass state to CSS via **Custom Properties**. This keeps visual logic in your stylesheet while JavaScript provides the raw data.
- **DON'T** use inline styles for static design (colors, padding, margins) that belong in a stylesheet.
- **DON'T** use inline event handlers (e.g., `onclick`). Trigger actions using `addEventListener()`.
### Code Example
```html
<body>
<!-- Progress with style-driven color data -->
<label for="upload-progress">Upload status:</label>
<progress id="upload-progress" class="loading-bar" value="0" max="100" style="--brand-hue: 200;"></progress>
<script>
const updateProgress = (percent, hue) => {
const bar = document.querySelector('.loading-bar');
bar.value = percent;
// Update dynamic style variable
if (hue) bar.style.setProperty('--brand-hue', hue);
};
// Example: Move to 85% and shift color to green (120)
setTimeout(() => updateProgress(85, 120), 1000);
</script>
</body>
```
```css
.loading-bar {
accent-color: hsl(var(--brand-hue, 200) 80% 50%);
transition: accent-color 0.3s ease;
}
```
guides/js/calculate-event-differentials.md
# Calculating Event Differentials with Temporal
Calculating the time elapsed between events (such as trial expirations, subscription durations, or prorated costs) has historically been difficult with the legacy `Date` object due to complexities with time zones, daylight saving time (DST), and inconsistent parsing.
The `Temporal` API provides a modern, robust solution for date and time arithmetic. Specifically, `Temporal.ZonedDateTime` and `Temporal.Duration` enable exact, DST-safe calculations of time differences.
## How to Implement
To calculate differentials between two events:
1. **Obtain ZonedDateTime objects**: Convert your inputs (dates and times) into `Temporal.ZonedDateTime` objects. This ensures calculations are time-zone aware.
2. **Calculate active time with `.since()`**: Use `currentZonedDateTime.since(startZonedDateTime)` to find the time elapsed since a start event.
3. **Calculate remaining time with `.until()`**: Use `currentZonedDateTime.until(endZonedDateTime)` to find the time remaining until a future event.
4. **Control precision with options**: Use `largestUnit`, `smallestUnit`, and `roundingMode` to control how the resulting duration is balanced and rounded.
### Example: Trial Expiration Calculation
```javascript
// 1. Get current time point in the system time zone
const now = Temporal.Now.zonedDateTimeISO();
const tz = now.timeZoneId;
// 2. Parse inputs (assuming ISO strings from form inputs)
const startDateStr = "2025-01-01";
const startTimeStr = "12:00:00";
const endDateStr = "2025-01-31";
const endTimeStr = "12:00:00";
const startDate = Temporal.PlainDate.from(startDateStr);
const startTime = Temporal.PlainTime.from(startTimeStr);
const start = startDate.toPlainDateTime(startTime).toZonedDateTime(tz);
const endDate = Temporal.PlainDate.from(endDateStr);
const endTime = Temporal.PlainTime.from(endTimeStr);
const end = endDate.toPlainDateTime(endTime).toZonedDateTime(tz);
// 3. Calculate difference using .since() and .until()
// By default, units larger than hours might not wrap automatically.
// Use largestUnit to ensure differences are expressed in larger units if applicable.
const timeActive = now.since(start, { largestUnit: 'year' });
const timeRemaining = now.until(end, { largestUnit: 'year' });
console.log(`Active: ${timeActive.days} days, ${timeActive.hours} hours`);
console.log(`Remaining: ${timeRemaining.days} days, ${timeRemaining.hours} hours`);
// 4. Compare dates
const isExpired = Temporal.ZonedDateTime.compare(now, end) > 0;
if (isExpired) {
console.log("Subscription is expired.");
}
```
## Strategic Implementation & Best Practices
- **DO** use `Temporal.ZonedDateTime` for calculations involving real-world events that occur in specific time zones (like subscription renewals or event scheduling).
- **DO** use `largestUnit` to specify the largest unit you want in the result (e.g., `'year'` or `'month'`). If you omit it, it defaults to `'auto'` which might not always sum up to years/months as expected for human-readable durations.
- **DO** use `.since()` when calculating time elapsed *since* a past event (e.g., `now.since(start)`), and `.until()` for time remaining *until* a future event (e.g., `now.until(end)`).
- **DO NOT** modify instances directly; `Temporal` objects are **immutable**. Operations like `add()`, `subtract()`, or `with()` return a *new* instance.
- **DO** use `Temporal.ZonedDateTime.compare` to check if one time point is after another. It returns `1` if the first is after the second, `-1` if before, and `0` if equal.
## Fallback Strategy
### Fallbacks & browser support for Temporal
Temporal has limited availability.
Supported by: Chrome 144 (Jan 2026), Edge 144 (Jan 2026), and Firefox 139 (May 2025).
Unsupported in: Safari.
For browsers that do not yet support the native `Temporal` API, use feature detection and a polyfill. The standard reference polyfill is `@js-temporal/polyfill`.
Note that the polyfill does not automatically assign the `Temporal` object to the global scope to avoid conflicts. You must manually assign it if your code relies on the global `Temporal` object.
```javascript
// Check if Temporal is supported natively
(async () => {
if (typeof Temporal === 'undefined') {
// Load the polyfill conditionally
const module = await import("https://esm.sh/@js-temporal/polyfill");
globalThis.Temporal = module.Temporal;
// Extend Date.prototype if needed
Date.prototype.toTemporalInstant = module.toTemporalInstant;
initializeApp();
}
})();
```guides/js/capture-location-agnostic-data.md
# Capturing Location-Agnostic Data with Temporal
Recording chronological data that should remain identical regardless of the viewer's location (such as birthdates, recurring alarms, or national holidays) has historically been error-prone with the legacy `Date` object. Because `Date` objects always represent a specific instant in time and are tied to a time zone, saving a date like "1990-01-01" can result in users in different time zones seeing "1989-12-31" due to offset shifts.
The `Temporal` API introduces "Plain" types—such as `Temporal.PlainDate` and `Temporal.PlainTime`—which have no concept of a time zone. These types represent calendar dates and wall-clock times exactly as you would read them off a calendar or a clock, making them ideal for location-agnostic data.
## How to Implement
To capture and display location-agnostic data:
1. **Use `Temporal.PlainDate` for dates**: For data like birthdates or holidays, use `Temporal.PlainDate.from()` to create an instance from an ISO 8601 string or an object.
2. **Use `Temporal.PlainTime` for times**: For data like a daily alarm or a preferred lunch time, use `Temporal.PlainTime.from()`.
3. **Display without conversion**: Since these objects are time-zone unaware, they will display the same values regardless of the user's local time zone.
### Example: Capturing a Birthdate
```javascript
// 1. Parse a date string from an input (e.g., "1990-01-01")
const birthdateStr = "1990-01-01";
const plainDate = Temporal.PlainDate.from(birthdateStr);
// 2. Display the date
// This will output "01/01/1990" (or equivalent) in any time zone
console.log(plainDate.toLocaleString('en-GB'));
// 3. Compare with standard Date (which might drift)
const dateObj = new Date("1990-01-01T00:00:00Z");
// In a UTC-5 time zone, this might print "31/12/1989"
console.log(new Intl.DateTimeFormat('en-GB', { timeZone: 'America/New_York' }).format(dateObj));
```
## Strategic Implementation & Best Practices
- **DO** use `Temporal.PlainDate` for "calendar dates" like birthdates, anniversaries, and holidays where the specific time of day or time zone is irrelevant.
- **DO** use `Temporal.PlainTime` for "wall-clock times" like a daily reminder at 9:00 AM, where the time should be 9:00 AM in whatever time zone the user happens to be in.
- **DO NOT** use Plain types if you need to represent a specific moment in physical time (an "instant"). Use `Temporal.Instant` or `Temporal.ZonedDateTime` for logs, event timestamps, or anything requiring time zone awareness.
- **DO** remember that `Temporal` objects are **immutable**. Methods like `add()` or `with()` return a new instance rather than modifying the original.
## Fallback Strategy
### Fallbacks & browser support for Temporal
Temporal has limited availability.
Supported by: Chrome 144 (Jan 2026), Edge 144 (Jan 2026), and Firefox 139 (May 2025).
Unsupported in: Safari.
For browsers that do not yet support the native `Temporal` API, use feature detection and a polyfill. The standard reference polyfill is `@js-temporal/polyfill`.
Note that the polyfill does not automatically assign the `Temporal` object to the global scope to avoid conflicts. You must manually assign it if your code relies on the global `Temporal` object.
```javascript
// Check if Temporal is supported natively
(async () => {
if (typeof Temporal === 'undefined') {
// Load the polyfill conditionally
const module = await import("https://esm.sh/@js-temporal/polyfill");
globalThis.Temporal = module.Temporal;
// Extend Date.prototype if needed
Date.prototype.toTemporalInstant = module.toTemporalInstant;
initializeApp();
}
})();
```guides/js/coordinate-global-events.md
# Coordinating Global Events with Temporal
Scheduling events across different time zones is notoriously difficult with the legacy `Date` object, especially around Daylight Saving Time (DST) transitions when hours can be skipped or repeated.
The `Temporal` API provides `Temporal.ZonedDateTime` to represent a date and time in a specific time zone, handling DST transitions automatically and predictably.
## How to Implement
To coordinate global events and handle potential DST conflicts:
1. **MANDATORY:** **Create a ZonedDateTime**: Use `Temporal.ZonedDateTime.from()` to create a time-zone-aware date-time object.
2. **MANDATORY:** **Handle Ambiguity**: Use the `disambiguation` option to control behavior when a time is ambiguous or does not exist (e.g., during clock changes).
3. **MANDATORY:** **Convert Time Zones**: Use `.withTimeZone()` to see the equivalent time in another location.
### Example: Scheduling and Conflict Detection
```javascript
// 1. Define the event time and target time zone
const date = "2025-03-09";
const time = "02:30"; // This time is skipped in New York during Spring Forward
const timeZone = "America/New_York";
const inputStr = `${date}T${time}[${timeZone}]`;
// 2. Detect conflicts using 'reject'
let hasConflict = false;
try {
// 'reject' throws RangeError if the time is ambiguous or does not exist
Temporal.ZonedDateTime.from(inputStr, { disambiguation: 'reject' });
} catch (e) {
if (e instanceof RangeError) {
hasConflict = true;
console.log("This time falls in a DST transition gap or overlap.");
}
}
// 3. Resolve the time safely using 'compatible' (default)
// 'compatible' will resolve to a valid time even if skipped or repeated
const hostTime = Temporal.ZonedDateTime.from(inputStr, { disambiguation: 'compatible' });
console.log(`Resolved time: ${hostTime.toString()}`);
// 4. Convert to another time zone (e.g., Tokyo)
const tokyoTime = hostTime.withTimeZone("Asia/Tokyo");
console.log(`Tokyo time: ${tokyoTime.toString()}`);
```
## Strategic Implementation & Best Practices
- **DO** use `Temporal.ZonedDateTime` for events that are bound to a specific geographical location (like a meeting in a specific city).
- **DO** use `disambiguation: 'reject'` if you need to detect and warn users about scheduling conflicts during DST transitions.
- **DO** use `disambiguation: 'compatible'` (the default) when you want the system to automatically pick a sensible time when conflicts occur.
- **DO NOT** use `Temporal.PlainDateTime` for global events, as it does not carry time zone information and cannot account for DST changes.
- **DO** use `.withTimeZone()` to calculate the equivalent time in other locations without mutating the original object (Temporal objects are immutable).
### Fallback strategies
Temporal has limited availability.
Supported by: Chrome 144 (Jan 2026), Edge 144 (Jan 2026), and Firefox 139 (May 2025).
Unsupported in: Safari.
For environments without native `Temporal` support, you must conditionally load the `@js-temporal/polyfill`.
```javascript
// Check if Temporal is supported natively
(async () => {
if (typeof Temporal === 'undefined') {
// Load the polyfill conditionally
const module = await import("https://esm.sh/@js-temporal/polyfill");
globalThis.Temporal = module.Temporal;
// Extend Date.prototype if needed
Date.prototype.toTemporalInstant = module.toTemporalInstant;
initializeApp();
}
})();
function initializeApp() {
// Your app logic here
console.log("Temporal is ready:", typeof Temporal);
}
```guides/js/format-human-readable-durations.md
# Formatting Human-Readable Durations with Temporal
Presenting elapsed time or durations to users in a readable format (e.g., "1 hour and 30 minutes") has historically required manual math or external libraries. The `Temporal` API's `Temporal.Duration` class simplifies this by providing structured duration objects and powerful "balancing" capabilities via the `round()` method.
## How to Implement
To format a duration:
1. (**MANDATORY**) **Create a Duration**: Use `Temporal.Duration.from()` to create a duration object from a set of units.
2. (**OPTIONAL**) **Apply Balancing**: Use the `round()` method with the `largestUnit` option to control how units are balanced. For example, to convert 90 minutes into hours and minutes, or to keep it as total minutes.
3. (**MANDATORY**) **Build the Display String**: Access the specific unit properties (like `.hours`, `.minutes`) to construct the human-readable string manually, or **(Recommended)** use `Intl.DurationFormat` for a localized, automatic approach.
### Example: Balancing and Localized Formatting
```javascript
// 1. Create a duration (e.g., from user input)
const duration = Temporal.Duration.from({ minutes: 90 });
// 2. Balance to hours (converts 90 minutes to 1 hour and 30 minutes)
const balanced = duration.round({ largestUnit: 'hours' });
// 3. Format using Intl.DurationFormat (Handles pluralization automatically)
const formatter = new Intl.DurationFormat('en', { style: 'long' });
console.log(formatter.format(balanced));
// Note: Output may vary by browser (e.g., "1 hour and 30 minutes" or "1 hour, 30 minutes")
```
### Best Practices
* **DO** use `Temporal.Duration.round()` with `largestUnit` to control the display strategy (detailed breakdown vs total count).
* **DO** use `Intl.DurationFormat` for localized string formatting and automatic pluralization, or fall back to manual construction if not supported.
* **DO NOT** rely on `Temporal.Duration.prototype.toString()` for user-facing text; it returns ISO 8601 strings (e.g., `PT1H30M`).
* **DO** use feature detection and a polyfill for environments lacking native support.
## Fallback strategies
### Fallbacks & browser support for Temporal
Temporal has limited availability.
Supported by: Chrome 144 (Jan 2026), Edge 144 (Jan 2026), and Firefox 139 (May 2025).
Unsupported in: Safari.
For browsers that do not yet support the native `Temporal` API, use feature detection and a polyfill. The standard reference polyfill is `@js-temporal/polyfill`.
Note that the polyfill does not automatically assign the `Temporal` object to the global scope to avoid conflicts. You must manually assign it if your code relies on the global `Temporal` object.
```javascript
// Check if Temporal is supported natively
(async () => {
if (typeof Temporal === 'undefined') {
// Load the polyfill conditionally
const module = await import("https://esm.sh/@js-temporal/polyfill");
globalThis.Temporal = module.Temporal;
// Extend Date.prototype if needed
Date.prototype.toTemporalInstant = module.toTemporalInstant;
initializeApp();
}
})();
```
### Intl.DurationFormat
Baseline status for Intl.DurationFormat: Newly available. It's been Baseline since 2025-03-04.
Supported by: Chrome 129 (Sep 2024), Edge 129 (Sep 2024), Firefox 136 (Mar 2025), and Safari 16.4 (Mar 2023).
If `Intl.DurationFormat` is not supported, you should feature-detect it and fall back to manual string construction by extracting the balanced duration properties.
* **Guidance:** Use `typeof Intl.DurationFormat !== 'undefined'` to check for support. If unsupported, extract properties like `.hours` and `.minutes` from the balanced `Temporal.Duration` object and combine them, handling pluralization properly.
```javascript
// 3. Format the display string
if (typeof Intl.DurationFormat !== 'undefined') {
// Use recommended Intl API if available
const formatter = new Intl.DurationFormat('en', { style: 'long' });
console.log(formatter.format(balanced));
} else {
// Fallback manual formatting (assuming duration is already balanced)
const h = balanced.hours;
const m = balanced.minutes;
const hoursStr = `${h} hour${h === 1 ? '' : 's'}`;
const minutesStr = `${m} minute${m === 1 ? '' : 's'}`;
console.log(`${hoursStr} and ${minutesStr}`);
}
```guides/js/manage-recurring-intervals.md
# Managing Recurring Intervals with Temporal
Calculating recurring intervals, such as subscription billing cycles or payroll periods, has historically been error-prone with the legacy `Date` object. Adding a month to a date like January 31st is ambiguous (should it be February 28th/29th or March 3rd?).
The `Temporal` API provides a clean solution with `Temporal.PlainDate` and its `.add()` method, which handles month-end transitions predictably using configurable overflow strategies.
## How to Implement
1. **MANDATORY:** **Parse the starting date**: Use `Temporal.PlainDate.from()` to create a date object.
2. **MANDATORY:** **Add the duration**: Use the `.add()` method with a duration object (e.g., `{ months: 1 }`).
3. **OPTIONAL:** **Specify overflow behavior**: Use the `overflow` option to control how invalid dates (like Feb 31) are handled.
- `'constrain'` (default): Clamps to the last valid day of the month.
- `'reject'`: Throws a `RangeError`.
### Example: Subscription Billing Cycle
```javascript
// 1. Parse the start date (e.g., billing starts on Jan 31st)
const startDate = Temporal.PlainDate.from('2024-01-31');
// 2. Add 1 month with default 'constrain' overflow
// Jan 31 + 1 month -> Feb 29 (2024 is a leap year)
const nextBillingDate = startDate.add({ months: 1 });
console.log(`Next billing: ${nextBillingDate.toString()}`); // 2024-02-29
// 3. Add 1 month to Feb 29
// Feb 29 + 1 month -> Mar 29
// Note: Day is preserved if valid, otherwise constrained.
const thirdBillingDate = nextBillingDate.add({ months: 1 });
console.log(`Third billing: ${thirdBillingDate.toString()}`); // 2024-03-29
// Example with 'reject' strategy
try {
// Jan 31 + 1 month with 'reject' throws because Feb 31 is invalid
const invalidDate = startDate.add({ months: 1 }, { overflow: 'reject' });
} catch (e) {
console.log("Caught expected error:", e.name); // RangeError
}
```
## Strategic Implementation & Best Practices
- **DO** use `Temporal.PlainDate` for calculations that do not depend on specific times or time zones (like calendar dates or billing cycles).
- **DO** understand the default `constrain` behavior. It is usually what users expect for billing (e.g., Jan 31 -> Feb 28/29 -> Mar 28/29).
- **DO NOT** modify instances directly; `Temporal` objects are **immutable**. Operations return a new instance.
- **DO** use `overflow: 'reject'` if you need to enforce that the resulting date must exist in the calendar and handle failures explicitly.
### Fallback strategies
Temporal has limited availability.
Supported by: Chrome 144 (Jan 2026), Edge 144 (Jan 2026), and Firefox 139 (May 2025).
Unsupported in: Safari.
For browsers that do not yet support the native `Temporal` API, use feature detection and a polyfill. The standard reference polyfill is `@js-temporal/polyfill`.
```javascript
// Check if Temporal is supported natively
(async () => {
if (typeof Temporal === 'undefined') {
// Load the polyfill conditionally
const module = await import("https://esm.sh/@js-temporal/polyfill");
globalThis.Temporal = module.Temporal;
// Extend Date.prototype if needed
Date.prototype.toTemporalInstant = module.toTemporalInstant;
initializeApp();
}
})();
function initializeApp() {
const date = Temporal.PlainDate.from('2024-01-31');
console.log(date.add({ months: 1 }).toString());
}
```guides/js/model-partial-time-concepts.md
# Modeling Partial Time Concepts with Temporal
Modeling date concepts that lack a full calendar date—such as credit card expirations, annual renewals, or daily alarms—has historically been error-prone with the legacy `Date` object. Developers often resort to using arbitrary days (like the 1st of the month) or parsing strings, leading to "day leakage" or incorrect calculations due to leap years and varying month lengths.
The `Temporal` API provides dedicated types for these partial concepts: `Temporal.PlainYearMonth`, `Temporal.PlainMonthDay`, and `Temporal.PlainTime`. These types ensure precision and avoid leaking irrelevant date components.
## Implementation Examples
### Monthly Expirations (Credit Cards, Billing Cycles)
Use `Temporal.PlainYearMonth` to represent a year and a month.
```javascript
// Create a PlainYearMonth from values
// Use explicit calendar to avoid mismatch issues in polyfill environments
const expiry = Temporal.PlainYearMonth.from({ year: 2027, month: 12, calendar: 'iso8601' });
// Get the current year/month
const currentMonth = Temporal.Now.plainDateISO().toPlainYearMonth();
// Calculate duration until expiry
// largestUnit ensures the difference is expressed in years if applicable
const duration = currentMonth.until(expiry, { largestUnit: 'years' });
if (duration.sign < 0) {
console.log("Expired");
} else if (duration.sign === 0) {
console.log("Expires this month");
} else {
console.log(`Expires in ${duration.years} years and ${duration.months} months`);
}
```
### Annual Recurring Dates (Birthdays, Renewals)
Use `Temporal.PlainMonthDay` to represent a month and a day without a year.
```javascript
// Create a PlainMonthDay for an annual event
// Include explicit calendar for polyfill safety
const birthday = Temporal.PlainMonthDay.from({ month: 10, day: 31, calendar: 'iso8601' });
// Check if it matches today's date components
const today = Temporal.Now.plainDateISO();
const isBirthdayToday = birthday.equals(today.toPlainMonthDay());
// To perform arithmetic (like days until next occurrence), convert to a full PlainDate
// by providing a specific year.
const birthdayThisYear = birthday.toPlainDate({ year: today.year });
```
### Wall-Clock Time (Alarms, Store Hours)
Use `Temporal.PlainTime` to represent a time of day without a date.
```javascript
// Create a PlainTime from a string
const alarmTime = Temporal.PlainTime.from("08:00:00");
// Add a duration to a PlainTime
const snoozedTime = alarmTime.add({ minutes: 10 });
console.log(`Original alarm: ${alarmTime.toString()}`);
console.log(`Snoozed alarm: ${snoozedTime.toString()}`);
```
## Strategic Implementation & Best Practices
- **DO** use `Temporal.PlainYearMonth` for values that only specify a month and year (like credit card expiry) to avoid leaking arbitrary day values.
- **DO** use `Temporal.PlainMonthDay` for annual events that ignore the year (like birthdays or anniversaries).
- **DO** use `Temporal.PlainTime` for daily schedules or alarms that are independent of the date.
- **DO NOT** try to perform arithmetic directly on `PlainMonthDay`. Convert it to a `PlainDate` first by providing a year, as the length of months varies by year.
- **DO** use explicit calendar properties (like `calendar: 'iso8601'`) when creating instances from objects to ensure safety across polyfill implementations.
## Fallback Strategy
### Fallbacks & browser support for Temporal
Temporal has limited availability.
Supported by: Chrome 144 (Jan 2026), Edge 144 (Jan 2026), and Firefox 139 (May 2025).
Unsupported in: Safari.
For browsers that do not yet support the native `Temporal` API, use feature detection and a polyfill. The standard reference polyfill is `@js-temporal/polyfill`.
Note that the polyfill does not automatically assign the `Temporal` object to the global scope to avoid conflicts. You must manually assign it if your code relies on the global `Temporal` object.
```javascript
// Check if Temporal is supported natively
(async () => {
if (typeof Temporal === 'undefined') {
// Load the polyfill conditionally
const module = await import("https://esm.sh/@js-temporal/polyfill");
globalThis.Temporal = module.Temporal;
// Extend Date.prototype if needed
Date.prototype.toTemporalInstant = module.toTemporalInstant;
initializeApp();
}
})();
```
guides/js/stabilize-reactive-state.md
# Stabilize Reactive State with Temporal
While some reactive systems (like [React](https://react.dev/)) rely strictly on reference equality to detect state changes, others (like [Vue](https://vuejs.org/) and [Svelte](https://svelte.dev/)) can track mutations to plain objects. However, for built-in objects like the legacy `Date` object, internal mutations (like `setHours()`) do not change the object's reference and are generally not tracked by any framework's default reactivity system. This leads to missed UI updates and hard-to-debug side effects.
The `Temporal` API solves this by providing immutable objects. Any operation that modifies a value (such as adding time or setting a field) returns a new instance with a new memory reference. This guarantees that state updates are always detected by reactive systems, ensuring UI stability.
## How to Implement
To stabilize reactive state using Temporal:
1. **Use Temporal types for state:** Store `Temporal` objects (like `Temporal.PlainDateTime` or `Temporal.PlainDate`) in your reactive state instead of legacy `Date` objects.
2. **Perform immutable updates:** When updating the state, use Temporal methods like `.add()`, `.subtract()`, or `.with()`. These methods return a new object.
3. **Pass the new reference to the state setter:** Use the newly created Temporal object to update your component state, triggering a reliable re-render.
## Example Code: Temporal vs Legacy Date in State
```javascript
// ❌ BAD: Mutating legacy Date breaks reactivity
let dateState = { deadline: new Date() };
function extendDeadlineBad() {
// Mutates the object in place. Reference remains the same!
dateState.deadline.setHours(dateState.deadline.getHours() + 1);
// Frameworks will skip re-rendering because
// prevState === nextState (same memory reference)
updateState(dateState);
}
// ✅ GOOD: Temporal ensures immutability and reliable reactivity
let temporalState = { deadline: Temporal.Now.plainDateTimeISO() };
function extendDeadlineGood() {
// Returns a new object with a new reference.
const newDeadline = temporalState.deadline.add({ hours: 1 });
// Create a new state object with the new Temporal reference
temporalState = { deadline: newDeadline };
// Frameworks will detect the reference change and re-render the UI
updateState(temporalState);
}
```
## Strategic Implementation & Best Practices
- **DO** use `Temporal` for any date/time values stored in reactive state to benefit from its immutability.
- **DO** use the most specific Temporal type for your use case (e.g., `Temporal.PlainDate` if you only need the calendar date) to avoid unnecessary complexity.
- **DO NOT** mutate `Date` objects in place when they are part of a component's state.
- **DO** ensure you handle environments without native support by conditionally loading a polyfill.
### Fallback strategies
Temporal has limited availability.
Supported by: Chrome 144 (Jan 2026), Edge 144 (Jan 2026), and Firefox 139 (May 2025).
Unsupported in: Safari.
Since the `Temporal` API is a newer feature and may not be supported in all browsers, you should feature-detect it and conditionally load a polyfill if needed.
```html
<!-- Conditionally load the Temporal polyfill only if not natively supported -->
<script>
if (typeof Temporal === "undefined") {
try {
const module = await import("https://esm.sh/@js-temporal/polyfill");
globalThis.Temporal = module.Temporal;
} catch (e) {
console.error("Failed to load Temporal polyfill:", e);
}
}
</script>
```guides/js/support-global-calendar-systems.md
# Supporting Global Calendar Systems with Temporal
The traditional JavaScript `Date` object is based on a proleptic Gregorian calendar, making it challenging to build applications for users who rely on other calendar systems, such as the Islamic (lunar), Hebrew (lunisolar), or Chinese (lunisolar) calendars. Developers previously had to rely on complex third-party libraries or manual calculations to support these systems.
The `Temporal` API provides first-class support for multiple calendar systems. By associating a calendar identifier with date objects, Temporal handles the complex arithmetic and formatting required for different cultural contexts natively.
## How to Implement
To support global calendar systems using Temporal:
1. **Associate a Calendar (Mandatory):** When creating or converting a Temporal object (like `Temporal.PlainDate`), you must specify the desired calendar system using `withCalendar()` to perform calendar-sensitive operations.
2. **Use Stable Identifiers (Mandatory for Lunisolar Calendars):** You must use `monthCode` rather than the numeric `month` index to identify specific months across years in lunisolar calendars. Only use this for calendars that use leap months, such as the Hebrew and Chinese calendars.
3. **Respect Calendar Invariants (Mandatory):** When iterating through months or days, you must not assume fixed values (like 12 months in a year or 31 days in a month). Use properties like `monthsInYear` and `daysInMonth` to ensure your code works across all calendars.
4. **Use Calendar-Aware Comparisons (Mandatory):** When comparing dates within a specific calendar system, you must use `Temporal.PlainDate.compare()` instead of comparing year/month/day properties manually. This correctly handles chronological ordering within the calendar rules.
## Example Code: Converting and Iterating Calendars
```javascript
// 1. Helper to check calendar support
function isCalendarSupported(calendarId) {
try {
return Intl.supportedValuesOf('calendar').includes(calendarId);
} catch {
// Fallback for environments where supportedValuesOf is not available
return false;
}
}
// 2. Get current date in default ISO 8601 calendar
const isoDate = Temporal.Now.plainDateISO();
// 3. Convert to Hebrew calendar if supported
const calendarId = 'hebrew';
const targetDate = isCalendarSupported(calendarId)
? isoDate.withCalendar(calendarId)
: isoDate; // Fallback to ISO if not supported
if (targetDate.calendar.id !== calendarId) {
console.warn(`Calendar ${calendarId} not supported; falling back to ISO 8601`);
}
// 4. Log properties specific to the calendar
console.log(`Calendar: ${targetDate.calendar.id}`);
console.log(`Year: ${targetDate.year}`);
console.log(`Month Code: ${targetDate.monthCode}`); // Stable across leap years
// 5. Safely iterate through months in the current year
for (let m = 1; m <= targetDate.monthsInYear; m++) {
console.log(`Month ${m} has ${targetDate.with({ month: m }).daysInMonth} days.`);
}
// 6. Compare dates within the same calendar
const today = Temporal.Now.plainDateISO().withCalendar(calendarId);
const comparison = Temporal.PlainDate.compare(targetDate, today);
const relative = comparison < 0 ? 'Past' : comparison > 0 ? 'Future' : 'Today';
console.log(`Timeline: ${relative}`);
// 7. Format for display using toLocaleString
const localizedDisplay = targetDate.toLocaleString('en-u-ca-hebrew', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
```
## Strategic Implementation & Best Practices
- **DO** use `monthsInYear` as the upper bound when looping through months, rather than assuming 12.
- **DO** use `monthCode` for identifying specific months in calendars that use leap months (e.g., Hebrew or Chinese), regardless of the year.
- **DO NOT** assume `date.month === 12` is the last month of the year. Use `date.month === date.monthsInYear`.
- **DO NOT** assume `inLeapYear === true` implies the year is only one day longer. In lunisolar calendars, it may add a full leap month.
- **DO** use `Temporal.PlainDate.compare()` when comparing two dates in a specific calendar system instead of comparing Year/Month/Day properties manually.
- **DO** use `toLocaleString()` to format dates for users instead of manual string concatenation.
- **DO** verify that the target calendar system is supported by the environment using `Intl.supportedValuesOf('calendar')` before creating calendar-specific Temporal objects.
- **DO** be aware that some calendars (like variants of the Islamic calendar) may rely on visual observation rather than fixed calculations. The `Temporal` API follows the environment's `Intl` implementation, which usually uses calculated approximations. For critical cultural or religious date calculations, verify with domain experts or use specialized libraries.
- **DO** account for era names when using calendars that use eras (e.g., Japanese, Buddhist), using `toLocaleString()`.
## Fallback Strategy
Temporal has limited availability.
Supported by: Chrome 144 (Jan 2026), Edge 144 (Jan 2026), and Firefox 139 (May 2025).
Unsupported in: Safari.
For production use in browsers that do not support it natively, you must use a polyfill.
The recommended approach is to progressively enhance by checking for native support and dynamically loading a polyfill like `@js-temporal/polyfill` if needed.
```javascript
/**
* Progressive Enhancement Fallback
*/
async function getTemporal() {
if (typeof Temporal !== 'undefined') {
return Temporal;
}
try {
// Load polyfill dynamically from CDN
const module = await import('https://esm.sh/@js-temporal/polyfill');
globalThis.Temporal = module.Temporal;
return module.Temporal;
} catch (e) {
console.error('Failed to load Temporal polyfill:', e);
throw e;
}
}
```
guides/performance/batch-analytics-events.md
# Debounce and batch multiple analytics events
Most analytics and telemetry data is low priority and you can safely defer sending it until the user leaves the page. The exception to this is if you need to deliver real-time updates, in which case timeliness matters.
The optimal way to provide real-time analytics updates, while still minimizing beacons, is to use `fetchLater()` with the `activateAfter` configuration option. This allows you to effectively debounce and batch all analytics events that occur within a given time window into a single beacon, that is reliably sent even if the user leaves the page before the timeout expires.
## How to implement
1. **Schedule the request:** As soon as any relevant analytics data is available, call `fetchLater()` with your data payload and pass an `activateAfter` value. This queues the data to be sent after that amount of time passes, or if the user leaves the page beforehand.
2. **Batch multiple events together:** If a new analytics event occurs before the `activateAfter` timeout expires, abort the previously scheduled request and call `fetchLater()` again (with the same `activateAfter` value) with the full event queue in a single payload.
3. **Reset the event queue when the timeout expires:** If a new analytics event occurs after the scheduled beacon has successfully sent (i.e. the `fetchLater()` result's `activated` value is `true`), reset the event queue.
3. **Let the browser handle the rest:** If the user navigates away or closes the tab before the `activateAfter` timeout expires, the browser will still reliably send the payload from your most recent `fetchLater()` call.
## Example code
This code tracks all `load` and `click` events on a page, and batches together all events that occur within a 10-second timeout.
```javascript
// Replace with your analytics endpoint.
const ANALYTICS_ENDPOINT = '/path/to/analytics/endpoint';
// Replace with a time window of your choice. All analytics events that
// occur within this time window will be batched together.
const BATCH_WINDOW = 10 * 1000;
// The maximum number of events to batch. Pick a number that is unlikely
// to overflow the fetchLater() quota for the page.
const MAX_QUEUE_SIZE = 100;
const eventQueue = [];
let fetchLaterResult;
let fetchLaterController;
function trackEvent(eventData) {
// If the previously queued beacon has already been sent, or if the
// max queue size has been met, reset the queue.
if (fetchLaterResult?.activated || eventQueue.length > MAX_QUEUE_SIZE) {
fetchLaterController = null;
fetchLaterResult = null;
eventQueue.length = 0;
}
eventQueue.push(eventData);
// Abort any pending beacons before creating a new one.
if (fetchLaterController) {
fetchLaterController.abort();
}
fetchLaterController = new AbortController();
// Schedule a fetch for the events to be sent when the batch window expires.
// IMPORTANT: wrap the call in a try/catch to handle quota errors.
try {
fetchLaterResult = fetchLater(ANALYTICS_ENDPOINT, {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify(eventQueue),
signal: fetchLaterController.signal,
activateAfter: BATCH_WINDOW,
});
} catch (error) {
// Handle errors as needed.
}
}
// Track page loads.
window.addEventListener('load', () => {
trackEvent({type: 'page_load'});
});
// Track click events.
window.addEventListener('click', (event) => {
trackEvent({type: 'click', target: serializeElement(event.target)});
});
```
## Best Practices
- **DO** use `fetchLater()` with the `activateAfter` option set to batch multiple analytics events that occur within close proximity of each other.
- **DO** use an `AbortController` to cancel the pending fetch in cases where a new analytics event occurs.
- **DO** check to ensure you do not batch too many events together and exceed the `fetchLater()` quota (currently roughly 64KB per origin).
- **DO** wrap calls to `fetchLater()` in a `try/catch` to handle quota errors.
- **DO** feature detect the presence of `fetchLater()` on `globalThis` and implement a fallback strategy for browsers that don't support the API.
- **DO NOT** use a `ReadableStream` object for the request body, as that will error.
## Browser support and fallback strategies
fetchLater has limited availability.
Supported by: Chrome 135 (Apr 2025) and Edge 135 (Apr 2025).
Unsupported in: Firefox and Safari.. Therefore, a fallback strategy is typically required.
However, given the improved reliability and performance benefits of this API, `fetchLater()` should be used if the browser supports it.
### `fetchLater()` polyfill
Use the following minimal `fetchLater()` polyfill, which implements the API as closely as possible in unsupporting browsers.
The only notable behavior difference with this polyfill is instead of sending the payload when the user leaves the page, it sends it whenever the page's `visibilityState` changes to "hidden", since this is the most reliable end-of-session signal that's widely available today.
```js
globalThis.fetchLater ??= function fetchLater(url, init = {}) {
let timeoutHandle;
let activated = false;
function sendNow() {
if (!(init.signal && init.signal.aborted)) {
// Use fetch keepalive if the browser supports it or if custom fetch
// parameters are specified (e.g. custom headers or methods).
// Otherwise fall back to `navigator.sendBeacon()`.
if (
'keepalive' in Request.prototype ||
init.method !== 'POST' ||
init.headers
) {
fetch(url, Object.assign({}, init, {keepalive: true}));
activated = true;
} else {
activated = navigator.sendBeacon(url, init.body);
}
}
destroy();
}
function destroy() {
document.removeEventListener('visibilitychange', sendNow);
clearTimeout(timeoutHandle);
}
if (document.visibilityState === 'hidden') {
// If the beacon was created while the page is already hidden, send data
// ASAP but wait until the next microtask to allow all sync code to run.
queueMicrotask(sendNow);
} else {
document.addEventListener('visibilitychange', sendNow);
if (typeof init.activateAfter === 'number' && init.activateAfter >= 0) {
timeoutHandle = setTimeout(sendNow, init.activateAfter);
}
}
if (init.signal) {
init.signal.addEventListener('abort', destroy);
}
return {
get activated() {
return activated;
},
};
};
```
guides/performance/break-up-long-tasks.md
# Break Up Long Tasks
Heavy computations or long loops can block the main thread, causing the page to become unresponsive. To prevent this, you should yield control back to the browser periodically. The `scheduler.yield()` API allows you to pause a long task and let the browser handle user input or rendering before continuing.
### Breaking up long tasks
Use `scheduler.yield()` inside async functions to break up work.
```javascript
async function processLargeArray(items) {
// DO: Set a time-based deadline 50 milliseconds into the future. 50
// milliseconds is the boundary for when a task becomes a long task.
let deadline = performance.now() + 50; // 50ms budget
for (const item of items) {
// Process the item
processItem(item);
// MANDATORY: Yield to the main thread periodically to keep the UI
// responsive. This can be done by checking if the deadline set earlier
// has been exceeded. When it has been, yield, then reset the deadline
// another 50 milliseconds into the future.
if (performance.now() >= deadline) {
await scheduler.yield();
deadline = performance.now() + 50;
}
}
}
```
### Fallback strategies
Scheduler API has limited availability.
Supported by: Chrome 129 (Sep 2024), Edge 129 (Sep 2024), and Firefox 142 (Aug 2025).
Unsupported in: Safari.
Some browsers may not support the `scheduler` API. You MUST implement a fallback using `setTimeout` to ensure code executes without breaking.
#### Fallback for `scheduler.yield()`
```javascript
async function processLargeArrayWithFallback(items) {
// DO: Set a time-based deadline 50 milliseconds into the future.
let deadline = performance.now() + 50;
for (const item of items) {
processItem(item);
// MANDATORY: Yield to the main thread periodically to keep the UI responsive.
if (performance.now() >= deadline) {
// DO: Feature detect scheduler.yield
if ('scheduler' in window && 'yield' in window.scheduler) {
await scheduler.yield();
} else {
// DO: Fallback to setTimeout for older browsers
await new Promise(resolve => setTimeout(resolve, 0));
}
deadline = performance.now() + 50;
}
}
}
```
guides/performance/calculate-total-foreground-time.md
# Calculate total foreground time
This guide details how to accurately calculate the total time a user spends actively viewing a page. Traditional metrics like time-on-page often incorrectly include time spent with the page in the background. By using the `VisibilityStateEntry` API, you can measure only the "foreground time," providing a better metric of user engagement.
## Implementing foreground time calculation
The `PerformanceTimeline` API exposes visibility state changes as performance entries. Rather than reacting to `visibilitychange` events and manually accumulating time throughout a session, you can query the entire visibility history at any time.
MANDATORY: You must query the `visibility-state` performance entries to calculate the true foreground time.
```javascript
/**
* Calculates total time the page was in the visible state.
*
* @returns {number} Total foreground time in milliseconds.
*/
function getTotalForegroundTime() {
// MANDATORY: Query the visibility-state entries from the performance timeline.
const entries = performance.getEntriesByType('visibility-state');
// Fallback: If the browser does not support VisibilityStateEntry,
// the API will gracefully return an empty array.
if (entries.length === 0) {
// Return total time since navigation start as a fallback.
return performance.now();
}
let totalForegroundTime = 0;
for (let i = 0; i < entries.length; i++) {
// Only calculate duration for periods where the state was 'visible'
if (entries[i].name === 'visible') {
const start = entries[i].startTime;
// The end time is the start time of the next state change,
// or the current time if this is the final entry.
const end = i + 1 < entries.length
? entries[i + 1].startTime
: performance.now();
totalForegroundTime += (end - start);
}
}
return totalForegroundTime;
}
```
## Fallbacks & browser support
Page visibility state has limited availability.
Supported by: Chrome 115 (Jul 2023) and Edge 115 (Jul 2023).
Unsupported in: Firefox and Safari.
The `VisibilityStateEntry` API is a modern addition to the Performance Timeline and may not be supported in all browsers.
Because `performance.getEntriesByType('visibility-state')` returns an empty array in unsupported browsers, feature detection is built into the calculation flow. You should always check if entries are returned before proceeding.
If the API is unsupported, the recommended fallback is to return `performance.now()`. This represents the total time since navigation, which serves as a reasonable upper bound for engagement time when visibility state history is unavailable.
```javascript
const entries = performance.getEntriesByType('visibility-state');
// If the array is empty, the API is likely unsupported.
if (entries.length === 0) {
// Fallback: Return total time since page load.
return performance.now();
}
```
guides/performance/conditional-async-dependencies.md
# Conditional Async Dependencies
Top-level `await` allows modules to act as asynchronous functions, meaning they can pause module execution to await promises. This is extremely useful for conditionally loading async dependencies—like polyfills or heavy secondary libraries—only when required by the browser.
By utilizing top-level await, you can encapsulate the conditional loading logic inside a single module, effectively preventing downstream consumer modules from executing until the dependency is fully loaded and ready.
### Conditional polyfill pattern
While top-level `await` can be used to conditionally load any async dependency that's a module, a good use of the conditional depenency loading pattern is to conditionally load polyfills for browsers that don't support a specific feature. This approach encapsulates feature detection and the dynamic import inside a single dependency module.
In the following case, the `popover` attribute polyfill is conditionally loaded if it isn't present on `HTMLElement.prototype`:
```javascript
// conditionally-load-polyfill.js
// Check if the feature is missing before doing work.
// MANDATORY: Prefer checking HTMLElement.prototype over window or document
// when checking for a global DOM attribute or property like popover.
if (!('popover' in HTMLElement.prototype)) {
// Use top-level await to pause the execution of any module that imports this file
// until the polyfill finishes downloading and executing.
await import('/path/to/popover-polyfill.js');
}
// Export a marker if needed by your application
export const polyfillLoaded = true;
```
```javascript
// main.js
// MANDATORY: Because conditionally-load-polyfill.js uses top-level await,
// this import will block execution of main.js until the polyfill is ready.
import './conditionally-load-polyfill.js';
// Now it is safe to use the feature (e.g., showing a popover)
const myPopover = document.getElementById('my-popover');
if (myPopover) {
myPopover.showPopover();
}
```
### Avoiding the Safari top-level `await` bug
**MANDATORY:** You must structure your imports carefully to avoid a bug where top-level await doesn't behave as expected in Webkit, which occurs when multiple modules *simultaneously* import a module that contains a top-level `await`:
```javascript
// DO NOT do this: importing the top-level await module from multiple sibling modules
// simultaneously will crash in Safari.
//
// a.js: import './conditionally-load-polyfill.js';
// b.js: import './conditionally-load-polyfill.js';
// main.js: import './a.js'; import './b.js'; // CRASH!
// INSTEAD, guarantee a single entry point:
// Import the top-level await module ONCE at the very top of your application tree.
import './conditionally-load-polyfill.js';
// Then import the rest of your application code, ensuring the await resolves first.
import './app.js';
```
### Fallback strategies
Top-level await has limited availability.
Supported by: Chrome 89 (Mar 2021), Edge 89 (Mar 2021), and Firefox 89 (Jun 2021).
Unsupported in: Safari.
Top-level `await` has been supported in all major browsers since 2021 (Chrome 89, Firefox 89, Safari 15). Because of this broad support, **you do not need to implement a fallback strategy for modern web applications.**
As long as you follow the guidance in the previous section to **avoid the Safari execution order bug**, you can safely rely on top-level `await` directly to manage your async dependencies.
You only need to avoid top-level `await` and fall back to standard asynchronous functions or dynamic `import()` orchestration if your application is explicitly required to support legacy browsers released before 2021.
guides/performance/defer-rendering-heavy-content.md
# Defer rendering heavy content
Web pages with extensive content—such as infinite scrolls, complex dashboards, or dense articles can suffer from slow initial rendering and sluggish interactions. Modern web technologies allow you to defer the rendering workload for content that is not immediately visible, significantly boosting performance without breaking accessibility or user expectations.
To optimize rendering, you can utilize the CSS `content-visibility` property and the HTML `hidden="until-found"` attribute. While both aid performance, they serve distinct use cases.
## When to use which
| Scenario / Example | Feature Applied | Performance Benefit |
| :--- | :--- | :--- |
| **1. Below the fold** (Delay initial load) | **`content-visibility: auto`** | Browser automatically offloads layout/paint workload until the container scrolls close to view, keeping standard page load speed frictionless. |
| **2. Toggle State** (Fast view switching) | **`content-visibility: hidden`** | Skips layout calculations for hidden divs but preserves style containment state, allowing for instantaneous toggling without structural shifts (superior to `display: none`). |
| **3. Searchable & Deferred** (Collapsible disclosures) | **`hidden="until-found"`** | For detailed instructions on combining rendering performance with find-in-page search accessibility, see `search-hidden-content` (via `npx -y modern-web-guidance@latest retrieve "search-hidden-content"`). |
## How to implement `content-visibility: auto`
### Choosing off-screen content
**MANDATORY**: You MUST carefully identify which elements receive `content-visibility: auto`.
- **DO** target large, self-contained layout blocks that are strictly **below the initial fold** (e.g., card items in an infinite feed, trailing comments, or bottom-heavy layout sections).
- **DO NOT** apply this property to elements within the initial, above-the-fold viewport. Doing so forces the browser to evaluate visibility boundaries before rendering, which paradoxically delays critical page load performance.
- **DO** target elements with deep or complex internal DOM structures to maximize rendering cost savings.
### Implementation steps
1. **MANDATORY**: Identify heavy sections that are confirmed to be off-screen on initial load.
2. **MANDATORY**: Apply `content-visibility: auto` to each of these off-screen elements.
3. **MANDATORY**: Provide an estimated layout structure size using `contain-intrinsic-size` on each element.
### How to use `contain-intrinsic-size`
**MANDATORY**: You MUST pair `content-visibility: auto` with `contain-intrinsic-size`. Failure to do so forces the browser to collapse the element to a 0px height when off-screen, causing severe layout shifting and scrollbar jumping as the user scrolls.
The `contain-intrinsic-size` CSS shorthand property acts as a placeholder dimension. Using the `auto` keyword enables the browser to "remember" the exact size once the element is finally rendered, using that calculated size over the placeholder if the element goes off-screen again.
### Example code
```css
/* DO ONLY apply this class to items OUTSIDE the initial layout viewport */
.heavy-section-deferred {
/* MANDATORY: Skips rendering calculations when off-screen */
content-visibility: auto;
/* Mandatory: Provide an estimated size to prevent layouts shifts.
- 'auto' is optional and enables the browser to remember the actual size
once rendered. It must be paired with a <length> value to be used for
the first render.
- 'none' tells the browser not to apply any intrinsic width to this element.
It can be used for either the height or the width value.
- '150px' is the estimated height of this element. This can be any valid
CSS <length> value.
*/
contain-intrinsic-size: auto none auto 150px;
}
```
## How to implement `content-visibility: hidden`
1. **Identify heavy sections:** Locate layout blocks that are initially hidden (e.g., extra rows in a large data table).
2. **Apply CSS:** Add `content-visibility: hidden` to the element.
3. **Reveal the element:** When the element should be revealed, change the `content-visibility` property to `visible` or `auto`.
### Example code
```css
.cached-view {
/* Hides content but caches rendering state */
content-visibility: hidden;
}
.cached-view.is-active {
content-visibility: visible;
}
```
Because `content-visibility: hidden` excludes the element and its children from the accessibility tree and find-in-page search, **DO NOT** use it if the content must remain discoverable while hidden. If you need hidden content to remain searchable via native Find-in-page, use `hidden="until-found"` as described in `search-hidden-content` (via `npx -y modern-web-guidance@latest retrieve "search-hidden-content"`).
## Best Practices
- **DO** use `contain-intrinsic-size` with `content-visibility: auto`. Failure to do so forces height recalculations on scroll, causing viewport layout jumping or visual glitches.
- **DO NOT** apply `content-visibility: auto` to elements inside the initial fold viewport, as this delays critical page rendering.
- **MANDATORY Accessibility Verification**: When applying `content-visibility: auto`, you MUST verify sequential keyboard reachability. In certain assistive technology configurations, off-screen nodes utilizing `content-visibility: auto` may be excluded from the accessibility tree or sequential navigation routes until focus is forcibly moved inside them. Test linear navigation across off-screen boundaries using keyboard alone.
## Fallback strategies
### `content-visibility` fallback
Baseline status for content-visibility: Newly available. It's been Baseline since 2025-09-15.
Supported by: Chrome 108 (Nov 2022), Edge 108 (Dec 2022), Firefox 130 (Sep 2024), and Safari 26 (Sep 2025).
When `content-visibility` is not supported it will be ignored by the browser. In most cases `content-visibility: auto` will not need a fallback, though without it performance gains will be lost. An unsupported browser will leave `content-visibility: hidden` elements completely visible. Use feature detection to implement a fallback.
```css
/* Default for everyone */
.inactive {
display: none;
}
/* Modern Browsers only */
@supports (content-visibility: hidden) {
.inactive {
display: block; /* Turn the layout box back on */
content-visibility: hidden;
}
}
```
guides/performance/defer-work-until-scroll-ends.md
# Defer Work Until Scroll Ends
Scrolling on the web should be smooth and responsive. Executing heavy tasks—such as layout recalculations, analytics data beacons tracking, or dynamic DOM updates—during scrolling can saturate the main thread, resulting in dropped frames and layout thrashing.
Historically, developers have relied on debouncing the `scroll` event using `setTimeout()` to guess when a scroll is finished. However, these debounced functions are notoriously unreliable. They may trigger while the user’s is still scrolling.
The `scrollend` event offers a highly reliable, performance-driven solution. The browser fires a `scrollend` event exactly when a scroll has rested, all transitions are finished, and a touch gesture has been released.
## How to Implement
To implement a defer-work pattern:
1. **Set Up a Scrollable Container**: Create a container with `overflow: auto` or `overflow: scroll`.
2. **Listen for `scroll` events**: Only use this listener for basic dynamic metrics or informative layout styling. Do not execute heavy work here.
3. **Listen for `scrollend` events**: Register a `scrollend` callback on the scroll container or the document itself.
4. **Execute Expensive work in the callback**: This is where it's safe to fetch dynamic content or trigger comprehensive DOM layouts.
## Example Code
```css
.scroll-container {
height: 300px;
overflow-y: auto;
}
```
```javascript
const scroller = document.querySelector('.scroll-container');
// 1. Informative feedback during scroll
scroller.addEventListener('scroll', () => {
// Avoid dynamic heavier data updates here
console.log('Scrolling dynamically... updates deferred');
});
// 2. Safe callback when scrolling rests
scroller.addEventListener('scrollend', () => {
// Run layout recalculations or analytical beacons updates here
const currentVisibleSection = findMostVisibleSection(scroller);
fetchAdditionalData(currentVisibleSection);
});
```
## Strategic Implementation & Best Practices
- **DO** use `scrollend` instead of debounced `scroll` events when firing layout data beacons or fetching new content content layout dynamically.
- **DO** consider pairing this with `scrollSnapChange` or `scrollSnapChanging` snap interactions if you're building carousels or testimonial galleries slides.
- **DO NOT** bundle layout-dependent dynamic updates inside dynamic visual scroll callbacks.
- **DO** consider that visual viewport zooming and scrolling triggers the `scrollend` event correctly.
## Fallback Strategy
Baseline status for scrollend: Newly available. It's been Baseline since 2025-12-12.
Supported by: Chrome 114 (May 2023), Edge 114 (Jun 2023), Firefox 109 (Jan 2023), and Safari 26.2 (Dec 2025).
For unsupported browsers, fall back to a debounced `scroll` event with `setTimeout` to dispatch a custom `scrollend` event.
```javascript
function initializeDemo() {
const scroller = document.querySelector('#scroller');
scroller.addEventListener('scrollend', () => {
// Safe execution
});
}
if ('onscrollend' in window) {
initializeDemo();
} else {
initializeDemo();
const scroller = document.querySelector('#scroller');
scroller.addEventListener('scroll', () => {
clearTimeout(window.scrollendtimer);
window.scrollendtimer = setTimeout(() => {
scroller.dispatchEvent(new CustomEvent('scrollend'));
}, 100);
});
}
```
guides/performance/deliver-optimized-decorative-images.md
# Deliver Optimized Decorative Images
Delivering optimized decorative images via CSS improves perceived performance without sacrificing visual quality. By using the `image-set()` CSS function, you can provide the browser with multiple options for a single background or mask image. You can specify modern formats (like AVIF or WebP) alongside different resolutions (like `1x` and `2x`). The browser will dynamically select the smallest compatible image that provides the appropriate pixel density for the user's device.
**CAUTION**: If the image is likely to be the Largest Contentful Paint (LCP) element (e.g., a large hero banner), be aware that images referenced in CSS via image-set() are not discoverable by the browser's preload scanner. This can significantly delay image loading and harm LCP. For LCP candidates, consider using a standard HTML `<img>` or `<picture>` tag instead or alternatively, preloading the image as well using `<link rel=preload>` option with a `media` attribute.
### Implementation
The `image-set()` function is used anywhere CSS expects an `<image>` value, most commonly in `background-image`, `content`, or `mask-image`. Note that while providing both the image format via `type()` and the resolution (like `1x` or `2x`) yields the best results, both of these arguments are optional.
```css
.gallery-item {
/* Provide multiple resolutions and formats using image-set() */
/* MANDATORY: Always order your formats from most optimized (AVIF) to least optimized (JPEG/PNG).
The browser will stop at the first supported format. */
background-image: image-set(
url("gallery.avif") type("image/avif") 1x,
url("gallery-2x.avif") type("image/avif") 2x,
url("gallery.webp") type("image/webp") 1x,
url("gallery-2x.webp") type("image/webp") 2x,
url("gallery.jpg") type("image/jpeg") 1x,
url("gallery-2x.jpg") type("image/jpeg") 2x
);
/* Standard decorative properties */
background-size: cover;
background-position: center;
}
```
### Fallback strategies
Baseline status for image-set(): Widely available. It's been Baseline since 2023-09-18.
Supported by: Chrome 113 (May 2023), Edge 113 (May 2023), Firefox 89 (Jun 2021), and Safari 17 (Sep 2023).
For older browsers that do not support the `image-set()` function, you **MUST** provide a standard image declaration *before* the `image-set()` rule. This progressive enhancement strategy relies on CSS's cascading nature: unsupported rules are ignored.
```css
.gallery-item {
/* MANDATORY: Fallback for browsers that do not support image-set() */
background-image: url("gallery.jpg");
/* Modern browsers will apply this and override the fallback */
background-image: image-set(
url("gallery.avif") type("image/avif") 1x,
url("gallery-2x.avif") type("image/avif") 2x,
url("gallery.jpg") type("image/jpeg") 1x,
url("gallery-2x.jpg") type("image/jpeg") 2x
);
}
```
guides/performance/deprioritize-background-fetches.md
# Deprioritize background fetches
When a page performs multiple simultaneous network requests, they often compete for the same bandwidth. Non-critical data such as analytics, logging, or background synchronization should be deprioritized so that user-initiated or critical data fetches can complete more quickly.
## How to implement
1. **Identify background requests**: Determine which `fetch()` calls are for non-essential data that doesn't impact the immediate user experience.
2. **Apply fetch priority**: Add the `priority: 'low'` option to the `fetch()` initialization object.
## Example code
```javascript
// Use high priority (default) for critical UI updates
const criticalData = await fetch('/api/data');
// Explicitly deprioritize background analytics
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(eventData),
// Lower the priority to prevent network contention
priority: 'low'
});
```
## Best practices
- **DO** use `priority: 'low'` for analytics, beacons, or telemetry data that isn't required for the current view.
- **DO** use `priority: 'low'` for "prefetching" data that the user *might* need later, ensuring it doesn't slow down what they need *now*.
- **DO NOT** use `priority: 'low'` for fetches that are critical to the user experience.
- **DO NOT** use the deprecated `importance` key in the fetch options object. The correct key is `priority`.
## Fallback strategy
Baseline status for Fetch priority: Newly available. It's been Baseline since 2024-10-29.
Supported by: Chrome 103 (Jun 2022), Edge 103 (Jun 2022), Firefox 132 (Oct 2024), and Safari 17.2 (Dec 2023).
The `priority` option in the Fetch API is a progressive enhancement. Browsers that do not support it will ignore the option and treat the request with default priority. No explicit feature detection or fallback logic is required for basic usage.
guides/performance/detect-initial-visibility-state.md
# Detect Initial Visibility State
Determining if a page was initially loaded in the background (e.g., opened in a new background tab) is critical for accurate performance monitoring. Pages loaded in the background often have delayed rendering and longer metric times (like First Contentful Paint). Identifying these pages allows you to filter them out of performance analytics to avoid skewed data.
The most accurate way to measure this is by using the `VisibilityStateEntry` API, which reliably records visibility changes on the browser's performance timeline, regardless of when your script actually executes.
### Detecting initial visibility and background time
MANDATORY: Use `performance.getEntriesByType('visibility-state')` to access the exact visibility history. Do not rely solely on checking `document.visibilityState` at execution time, as it is susceptible to race conditions.
```javascript
/**
* Accurately determines visibility state history using the Performance API.
*/
function getVisibilityInfo() {
// Retrieve VisibilityStateEntry members:
const entries = performance.getEntriesByType('visibility-state');
if (entries.length > 0) {
const firstEntry = entries[0];
// If the first performance entry for visibility is 'hidden',
// the page was loaded in the background.
const initiallyBackgrounded = firstEntry.name === 'hidden';
// Find the precise, high-resolution timestamp of when the page
// was first backgrounded.
let timeBackgrounded = null;
for (const entry of entries) {
if (entry.name === 'hidden') {
// entry.startTime is used because it provides the exact browser
// timestamp of the visibility change, which is required for precision
timeBackgrounded = entry.startTime;
break;
}
}
return {
initiallyBackgrounded,
timeBackgrounded
};
}
}
```
### Fallback strategies
Page visibility state has limited availability.
Supported by: Chrome 115 (Jul 2023) and Edge 115 (Jul 2023).
Unsupported in: Firefox and Safari.
For unsupported environments, you may fall back to checking the `document.visibilityState` property or listening for the `visibilitychange` event.
**MANDATORY:** You must understand that this fallback approach is often **highly inaccurate for determining initial background state**. Because scripts can load and execute asynchronously, a page could be opened in a background tab and then foregrounded by the user *before* your script has finished downloading and executing. When your script finally runs, `document.visibilityState` will synchronously read as `'visible'`, and you will incorrectly assume the page was loaded in the foreground, completely missing its initial hidden state. Furthermore, the fallback timestamp lacks the internal precision of the Performance API. If precision is a high priority, do not use the fallback.
```javascript
/**
* Fallback implementation using document.visibilityState.
* This approach is prone to race conditions if the script loads asynchronously.
*/
function getFallbackVisibilityInfo() {
// Check the state exactly when this script executes.
// This will fail to detect an initial background state if the user
// foregrounded the page before this script executed.
let initiallyBackgrounded = document.visibilityState === 'hidden';
// If it's hidden now, we approximate that it was hidden from load (time 0).
let timeBackgrounded = initiallyBackgrounded ? 0 : null;
// Listen for future visibility changes to capture if it is backgrounded later.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && timeBackgrounded === null) {
// performance.now() is used here as a fallback, but it only gives
// us the time the event listener fired, not the precise internal
// browser time the visibility actually changed.
timeBackgrounded = performance.now();
}
});
return {
get initiallyBackgrounded() { return initiallyBackgrounded; },
get timeBackgrounded() { return timeBackgrounded; }
};
}
// Modern implementation using VisibilityStateEntry API.
function getVisibilityInfo() {
// Code omitted here would be the same modern
// implementation shown earlier in this guide
// ...
}
// DO: Detect if the VisibilityStateEntry API is available
if ('VisibilityStateEntry' in window) {
// DO: If VisibilityStateEntry is available, use it first:
getVisibilityInfo();
} else {
// DO: If VisibilityStateEntry is unavailable, fall back to `document.visibilityState`:
getFallbackVisibilityInfo();
}
```
guides/performance/efficient-background-processing.md
# Efficient Background Processing
Pause heavy background tasks when a component is not being rendered by the browser to conserve system resources and battery life.
## Overview
The `content-visibility: auto` property allows the browser to skip rendering calculations for elements that are far outside the viewport. When the browser decides to skip or resume rendering for an element, it fires the `contentvisibilityautostatechange` event on that element.
By listening to this event, you can pause expensive operations like `<canvas>` animations, WebGL rendering, or high-frequency WebSocket data polling when they are not needed, and resume them just-in-time when the browser prepares to display the content.
### `contentvisibilityautostatechange` vs. `IntersectionObserver`
It is important to understand when to use which API:
* **Use `IntersectionObserver` for application logic** tied to the exact visual visibility of an element in the viewport (e.g., lazy-loading data, infinite scroll triggers).
* **Use `contentvisibilityautostatechange` for rendering-heavy work** (like complex canvas updates or heavy DOM mutations). This event ties directly to the browser's internal rendering lifecycle. The browser often starts rendering an element before it actually appears on screen (the pre-render margin). This event tells you when that happens, ensuring your content is ready to be seen.
## Implementation
### 1. Apply CSS Content Visibility
Set `content-visibility: auto` on the heavy container and provide a placeholder size to prevent scrollbar jumping.
```css
.heavy-component {
/* Defer rendering work when off-screen */
content-visibility: auto;
/* Mandatory: Provide a placeholder size to prevent layouts shifts.
- 'auto' is optional and enables the browser to remember the actual size
once rendered. It must be paired with a <length> value to be used for
the first render.
- 'none' tells the browser not to apply any intrinsic width to this element.
It can be used for either the height or the width value.
- '500px' is the estimated height of this element. This can be any valid
CSS <length> value. Replace it with the expected height of your
component.
*/
contain-intrinsic-size: auto none auto 500px;
}
```
### 2. Listen for State Changes
Add an event listener for `contentvisibilityautostatechange` to pause or resume background tasks.
> **Important:** The `contentvisibilityautostatechange` event does not bubble in some browser implementations. To handle this event reliably, you must either:
> - Attach the event listener directly to the element that has `content-visibility: auto` applied.
> - Use a capturing event listener (`{ capture: true }`) if you are delegating events to a parent container.
```javascript
const component = document.querySelector('.heavy-component');
// Option 1: Direct listener (recommended)
component.addEventListener('contentvisibilityautostatechange', (event) => {
if (event.skipped) {
// The browser skipped rendering this content.
// DO NOT perform heavy mutations or animation loops here.
stopSimulation();
pauseWebSocketPolling();
} else {
// The browser is about to render the content.
// Resume your work so it is ready when visible.
startSimulation();
resumeWebSocketPolling();
}
});
// Option 2: Capturing listener for event delegation
document.addEventListener('contentvisibilityautostatechange', (event) => {
if (event.target.matches('.heavy-component')) {
if (event.skipped) {
stopSimulation();
} else {
startSimulation();
}
}
}, { capture: true });
```
### Fallback strategies
Baseline status for content-visibility: Newly available. It's been Baseline since 2025-09-15.
Supported by: Chrome 108 (Nov 2022), Edge 108 (Dec 2022), Firefox 130 (Sep 2024), and Safari 26 (Sep 2025).
The `content-visibility` property and the associated `contentvisibilityautostatechange` event are progressive enhancements. In browsers that do not support them:
* The CSS property is ignored, and the content is rendered normally.
* The event never fires, so background tasks will continue to run as they normally would without optimization.
If you must support pausing tasks on older browsers, you can fallback to using `IntersectionObserver` as a rough approximation. This helps save battery and CPU on older devices too.
```javascript
// Fallback using IntersectionObserver for older browsers
const target = document.getElementById('target-container');
// Check if content-visibility is supported
const isSupported = 'contentVisibility' in document.documentElement.style;
if (!isSupported) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// The element is close to the screen. Start work!
startSimulation();
} else {
// The element is far away. Pause work!
stopSimulation();
}
});
}, {
// Use rootMargin to start rendering before it hits the screen
rootMargin: '200px'
});
observer.observe(target);
}
```
guides/performance/faster-spa-view-transitions.md
# Faster SPA View Transitions via State Caching
Enable instant navigation between views in a Single-Page Application (SPA) by caching the rendered state of inactive views instead of destroying them.
## Overview
Traditionally, when a user navigates between tabs or views in an SPA, developers either destroy the old view or hide it using `display: none`. Both approaches require the browser to recreate or recalculate the full layout and paint when the user returns to that view.
By using `content-visibility: hidden` on inactive views, the browser removes the element’s contents from the layout flow and stops painting it, but *retains* its cached rendering state in memory. When the user switches back, the view restores nearly instantly.
### The CPU vs. RAM Trade-off
While this approach offers massive performance benefits, it introduces a specific trade-off that you must manage carefully:
* **CPU Savings:** Massive. The browser completely skips layout and paint passes for hidden views.
* **RAM Cost:** High. The browser keeps all DOM nodes, event listeners, and state for the hidden view in memory.
#### When This Trade-off Becomes Dangerous
* **DO** use this strategy for simple applications with a small, predictable number of views (e.g., a 3-to-5 tabbed interface).
* **DO NOT** unconditionally cache every view in highly dynamic applications that generate dozens of unique reports or use infinite dynamic routes. Doing so will eventually cause severe memory bloat and may crash the browser on low-end devices.
* **MANDATORY:** If your application is highly dynamic, you MUST implement an **eviction strategy** (like a Least Recently Used cache) to destroy old views when a memory threshold is reached.
## Implementation
### 1. Configure View Containers
Set `content-visibility: hidden` on views that are not currently active.
> **Note:** The `content-visibility: hidden` property hides an element's *contents*, but the element itself remains styled and visible. Its background, borders, padding, and margins will still be painted by the browser.
```css
.spa-view.inactive {
/* MANDATORY: Use hidden to cache the rendering state of inactive views */
content-visibility: hidden;
/* Optional: Prevent hidden views from taking up physical space in layout flow */
position: absolute;
}
```
### 2. Manage Focus
When swapping views, ensure you manage keyboard focus correctly to preserve accessibility.
```javascript
function switchToView(viewId) {
// Hide all views
document.querySelectorAll('.spa-view').forEach(view => {
view.classList.add('inactive');
view.setAttribute('aria-hidden', 'true');
});
// Show the target view
const activeView = document.getElementById(viewId);
activeView.classList.remove('inactive');
activeView.setAttribute('aria-hidden', 'false');
// MANDATORY: Move focus to the new view to ensure a logical tab-order
activeView.focus();
}
```
### Fallback strategies
Baseline status for content-visibility: Newly available. It's been Baseline since 2025-09-15.
Supported by: Chrome 108 (Nov 2022), Edge 108 (Dec 2022), Firefox 130 (Sep 2024), and Safari 26 (Sep 2025).
The `content-visibility` property degrades gracefully. In browsers that do not support it:
* The property is ignored.
* To prevent fallback browsers from rendering all views simultaneously, you should provide a fallback to `display: none` in your CSS:
```css
@supports not (content-visibility: hidden) {
.spa-view.inactive {
display: none;
}
}
```
guides/performance/flicker-free-client-side-ab-testing.md
# Flicker-Free Client-Side A/B Testing
## The Problem
Client-side A/B testing tools work by loading a script that modifies the DOM after the browser has already begun constructing the page. Without intervention, the user briefly sees the original content before it flickers or flashes to the experiment variant. Testing platforms have historically worked around this with "anti-flicker snippets" that hide the entire page with `opacity: 0` until the experiment script finishes or an arbitrary timeout (typically 4 seconds) elapses. This approach sacrifices progressive rendering, allows accidental clicks on invisible content, and introduces unnecessary paint cycles.
## The Solution
The `blocking=render` attribute allows a `<script>` or `<link>` element placed in the `<head>` to block rendering—but not parsing—until the resource has been fetched and executed. This gives experimentation scripts the same render-blocking behavior that stylesheets have by default, ensuring the browser never paints the page until the experiment variant has been applied. No opacity hacks, no arbitrary timeouts, and no flicker.
### Implementation Strategy
1. **MANDATORY:** Place the experimentation script in the document `<head>` and add `blocking="render"`.
2. **MANDATORY:** Ensure the script either has `type="module"` (preferred for inline scripts) or `async` (preferred for external scripts with a `src` value).
3. **DO** keep the experimentation script small and fast. Because rendering is blocked until the script executes, a large or slow script directly delays first paint.
4. **DO NOT** use `blocking="render"` on scripts that do not need to run before first paint. It is intended only for scripts whose output must be visible in the initial render.
5. **DO NOT** apply `blocking="render"` to scripts outside the `<head>`. Only scripts in the `<head>` can block rendering.
## Implementation Guide
### Basic Setup
MANDATORY: Load the experimentation script with both `async` and `blocking="render"`. The `async` attribute ensures the script does not block HTML parsing (the browser continues building the DOM while fetching the script). The `blocking="render"` attribute ensures the browser does not paint anything until the script has executed.
```html
<head>
<!--
MANDATORY: Both `async` and `blocking="render"` are required.
- `async`: Prevents parser-blocking, so the DOM is built in parallel.
- `blocking="render"`: Holds rendering until the script executes,
ensuring experiment changes are applied before the user sees anything.
-->
<script
src="https://cdn.example.com/experiment-sdk.js"
async
blocking="render"
></script>
</head>
```
### Loading Experiment-Specific Styles
If the experiment requires a variant stylesheet, use `blocking="render"` on the `<link>` element. Stylesheets in the `<head>` already block rendering by default, but dynamically injected stylesheets or those added via script do not. Use `blocking="render"` explicitly when the stylesheet is added dynamically or when you want to be explicit about the intent.
```html
<head>
<!--
DO: Use blocking="render" on experiment stylesheets that are
dynamically inserted or conditionally loaded by the experiment SDK.
This ensures variant styles are applied before first paint.
-->
<link
rel="stylesheet"
href="https://cdn.example.com/experiment-variant-b.css"
blocking="render"
>
</head>
```
### Inline Experiment Script
If the experiment logic is lightweight enough to inline, use an inline module script with `blocking="render"`. This is useful when the experiment logic fetches a configuration and applies DOM changes directly.
```html
<head>
<!--
DO: Use an inline module script when the experiment logic is small.
Module scripts are deferred by default (non-parser-blocking),
and blocking="render" ensures rendering waits for execution.
-->
<script type="module" blocking="render">
// Fetch the experiment configuration from your testing platform.
const config = await fetch('/api/experiment?id=homepage-cta')
.then(res => res.json());
// Apply the variant by setting a data attribute on <html>.
// CSS rules keyed to this attribute will style the variant.
document.documentElement.dataset.variant = config.variant;
</script>
<style>
/* Default styles (control group) */
.cta-button {
background-color: blue;
}
/* Variant B styles, activated by the data attribute */
[data-variant="b"] .cta-button {
background-color: green;
}
</style>
</head>
```
## Best Practices
- **MANDATORY:** The experimentation script MUST execute quickly. A slow script delays all rendering. Set a performance budget (e.g., under 100ms execution time) for the render-blocking script.
- **DO** split heavy experiment logic from the render-blocking script. Load a small, render-blocking stub that applies the variant, then load heavier tracking or analytics scripts separately with `async` or `defer` (without `blocking="render"`).
- **DO** use a data attribute on `<html>` or `<body>` to signal the active variant, and use CSS selectors keyed to that attribute for variant styling. This avoids direct DOM manipulation in the render-blocking script.
- **DO NOT** use `blocking="render"` on analytics, tracking, or other non-visual scripts. Only scripts that must change what the user sees on first paint should block rendering.
- **DO NOT** combine `blocking="render"` with legacy anti-flicker snippets. They solve the same problem; using both creates unnecessary delays.
## Fallback Strategies
blocking="render" has limited availability.
Supported by: Chrome 105 (Sep 2022), Edge 105 (Sep 2022), and Safari 18.2 (Dec 2024).
Unsupported in: Firefox.
The `blocking` attribute is not supported in all browsers. In browsers that do not support it, the attribute is ignored and the script loads with its default behavior (`async` in the examples above), which may result in flicker. A fallback is required to prevent flicker in unsupported browsers.
### Fallback: Anti-Flicker Snippet
DO: Use a lightweight anti-flicker snippet as a fallback only when `blocking="render"` is not supported. Feature-detect support and skip the fallback in browsers that handle it natively.
```html
<head>
<!--
DO: Load the experiment script with blocking="render" for
browsers that support it. This is the preferred approach.
-->
<script
src="https://cdn.example.com/experiment-sdk.js"
async
blocking="render"
></script>
<script>
// DO: Only apply the anti-flicker fallback in browsers
// that do not support blocking="render".
if (!Object.hasOwn(HTMLScriptElement.prototype, 'blocking')) {
// Hide the page until the experiment script runs.
document.documentElement.classList.add('ab-loading');
// DO: Set a timeout to reveal the page if the experiment
// script takes too long. This prevents an indefinitely
// blank page on slow connections. Adjust the timeout
// to match your experiment SDK's expected load time.
setTimeout(() => {
document.documentElement.classList.remove('ab-loading');
}, 4000);
}
</script>
<style>
/*
DO: Use opacity to hide content during experiment loading.
This is only applied when blocking="render" is unsupported.
*/
.ab-loading {
opacity: 0 !important;
}
</style>
</head>
```
```javascript
// DO: In your experiment SDK's initialization callback,
// remove the fallback class to reveal the page.
function onExperimentReady() {
document.documentElement.classList.remove('ab-loading');
}
```
## Other Considerations
1. **Performance Impact**: `blocking="render"` trades first-paint speed for visual correctness. Monitor Largest Contentful Paint (LCP) and First Contentful Paint (FCP) to ensure the experimentation script is not adding excessive delay.
2. **Third-Party Script Reliability**: If the experiment SDK is hosted on a third-party CDN, a CDN outage could block rendering entirely. The browser applies its own timeout heuristics (which may be longer than 4 seconds), but there is no developer-controlled timeout for `blocking="render"`. Ensure the third-party provider has strong uptime guarantees.
3. **Server-Side Alternatives**: For performance-critical pages, consider server-side A/B testing (where the server renders the correct variant directly) instead of client-side testing. Server-side approaches eliminate flicker entirely without any render-blocking cost. Use client-side `blocking="render"` only when server-side testing is not feasible.
guides/performance/full-session-analytics.md
# Reliably measure full-session analytics and telemetry
To reliably analytics and telemetry data that covers the entirety of a user's visit to a web page (not just until page load) use the `fetchLater()` API.
The `fetchLater()` API is the most reliably way to send data to a server in cases where a response is not required and delivery timing is not urgent, which applies to data such as user analytics, telemetry, error tracking, and performance metrics like Core Web Vitals.
Older technique like creating an `<img>` pixel in an `unload` event listener are notoriously unreliably (especially on mobile) and can negatively impact performance (by making pages ineligible for bfcache).
## How to implement
1. **Schedule the request:** As soon as relevant data is available, call `fetchLater()` with your data payload. This queues the data to be sent later.
2. **Update the payload as needed:** If the user generates more data or the state changes, abort the previously scheduled request and call `fetchLater()` again with the fully updated snapshot.
3. **Let the browser handle the rest:** When the user navigates away or closes the tab, the browser will reliably send the payload from your most recent `fetchLater()` call.
## Example code
This code measures the session duration of a user's visit to a page using `fetchLater()` to queue a new beacon every 10 seconds with the updated session duration.
```javascript
const ANALYTICS_ENDPOINT = '/path/to/analytics/endpoint';
const sessionData = {
duration: 0,
id: crypto.randomUUID(),
};
let fetchLaterController = null;
function queueBeacon() {
// Abort any pending beacons before creating a new one.
if (fetchLaterController) {
fetchLaterController.abort();
}
fetchLaterController = new AbortController();
// Update session duration to the current page time.
sessionData.duration = performance.now();
// Schedule a fetch for the data payload to be sent later.
// IMPORTANT: wrap the call in a try/catch to handle quota errors.
try {
fetchLater(ANALYTICS_ENDPOINT, {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify(sessionData),
signal: fetchLaterController.signal,
});
} catch (error) {
// Handle errors as needed.
}
}
// Update the session data and queue a new beacon every 10 seconds.
setInterval(queueBeacon, 10000);
```
## Best Practices
- **DO** use `fetchLater()` to send data to a server in any situation where a response is not necessary, and it's not critical that the data is sent immediately.
- **DO** use an `AbortController` to cancel the pending fetch in cases where the data may need to be updated before the user leaves the page
- **DO** minimum the payload size to avoid exceeding the quota (currently roughly 64KB per origin).
- **DO** wrap calls to `fetchLater()` in a `try/catch` to handle quota errors.
- **DO** feature detect the presence of `fetchLater()` on `globalThis` and implement a fallback strategy for browsers that don't support the API.
- **DO NOT** use a `ReadableStream` object for the request body, as that will error.
## Browser support and fallback strategies
fetchLater has limited availability.
Supported by: Chrome 135 (Apr 2025) and Edge 135 (Apr 2025).
Unsupported in: Firefox and Safari.
A fallback strategy is required if `fetchLater()` doesn't meet your Baseline target. However, given the improved reliability and performance benefits of this API, `fetchLater()` should be used if the browser supports it.
The recommended fallback strategy is to use the polyfill below, which internally handles unsupporting browsers using `fetch()` with `keepalive` or `navigator.sendBeacon()`. Your own code MUST call `fetchLater()` directly — never call `fetch()`, `sendBeacon()`, or other beacon APIs yourself.
### `fetchLater()` polyfill
Use the following minimal `fetchLater()` polyfill, which implements the API as closely as possible in unsupporting browsers.
The only notable behavior difference with this polyfill is that it uses `visibilitychange` to detect when the user leaves, rather than relying on the browser's native unload handling. This is an internal implementation detail — your code does not need to listen for `visibilitychange` or any other page lifecycle events. Just call `fetchLater()` and the polyfill handles delivery.
```js
globalThis.fetchLater ??= function fetchLater(url, init = {}) {
let timeoutHandle;
let activated = false;
function sendNow() {
if (!(init.signal && init.signal.aborted)) {
// Use fetch keepalive if the browser supports it or if custom fetch
// parameters are specified (e.g. custom headers or methods).
// Otherwise fall back to `navigator.sendBeacon()`.
if (
'keepalive' in Request.prototype ||
init.method !== 'POST' ||
init.headers
) {
fetch(url, Object.assign({}, init, {keepalive: true}));
activated = true;
} else {
activated = navigator.sendBeacon(url, init.body);
}
}
destroy();
}
function destroy() {
document.removeEventListener('visibilitychange', sendNow);
clearTimeout(timeoutHandle);
}
if (document.visibilityState === 'hidden') {
// If the beacon was created while the page is already hidden, send data
// ASAP but wait until the next microtask to allow all sync code to run.
queueMicrotask(sendNow);
} else {
document.addEventListener('visibilitychange', sendNow);
if (typeof init.activateAfter === 'number' && init.activateAfter >= 0) {
timeoutHandle = setTimeout(sendNow, init.activateAfter);
}
}
if (init.signal) {
init.signal.addEventListener('abort', destroy);
}
return {
get activated() {
return activated;
},
};
};
```
guides/performance/identify-heavy-scripts.md
# Identify heavy-running JavaScript
Heavy-running JavaScript can have a detrimental effect on both page load performance and interactivity. Modern web applications are more heavily reliant on JavaScript than ever before, from multiple sources. These include the application code itself (and the framework code it relies on), as well as third-party scripts that add functionality like chat widgets and video players. Behind-the-scenes analytics and marketing scripts are also common contributors that are all too easy to forget.
Identifying root causes of an unresponsive web page can be tricky with certain expertise required to run web performance tracing or profiling and how to interpret the results. Additionally field data is often very different to lab data, which only replicates a small subset of real user scenarios. This can make it difficult to identify the root causes of poor performance, especially for interactions.
The Long Animation Frames API is a lightweight API that can be used to identify heavy-running JavaScript in the field. A heavy-running script can be either a single long-running script, or a script that runs multiple times during the page lifecycle.
## How to implement
Long animation frames are monitored using the `PerformanceObserver` interface. It emits a `long-animation-frame` entry when an animation frame takes longer than 50ms to render. The entry contains information about the long animation frame, including the duration of the frame and the scripts that were executed during the frame.
The `long-animation-frame` entry contains a `scripts` property which is an array of `PerformanceScript` objects. Each `PerformanceScript` object contains information about the script that was executed during the long animation frame, including the `sourceURL` and `duration` of the script.
### Example of identifying the longest running scripts that contribute to long animation frames
```javascript
// Accumulate all script entries across the page lifecycle so no
// data is lost between observer callbacks.
const allScripts = [];
const observer = new PerformanceObserver(list => {
// Collect all script entries across frames to find the biggest offenders.
allScripts.push(...list.getEntries().flatMap(entry => entry.scripts));
// Group by sourceURL so you can identify which scripts contribute
// the most total time, even if each individual invocation is short.
const scriptSource = [...new Set(allScripts.map(script => script.sourceURL))];
const scriptsBySource = scriptSource.map(sourceURL => ([sourceURL,
allScripts.filter(script => script.sourceURL === sourceURL)
]));
const processedScripts = scriptsBySource.map(([sourceURL, scripts]) => ({
sourceURL,
count: scripts.length,
totalDuration: scripts.reduce((subtotal, script) => subtotal + script.duration, 0)
}));
// Only include scripts above a certain threshold to reduce noise.
const heavyScripts = processedScripts.filter(script => {
return script.totalDuration > 100;
});
// Sort by total duration so the worst offenders appear first,
// making it easier to prioritize optimization efforts.
heavyScripts.sort((a, b) => b.totalDuration - a.totalDuration);
// Log to the console for local debugging. In production, replace
// this with a call to send the data to your analytics service.
console.table(heavyScripts);
});
// Use buffered: true to capture any long frames that occurred before
// this observer was registered.
observer.observe({type: 'long-animation-frame', buffered: true});
```
## Best Practices
- **DO** prefer the Long Animation Frames API over alternatives like the JS Self-Profiling API, which carries higher runtime overhead.
- **DO** summarize the key information as the Long Animation Frames API contains a lot of detail.
- **DO** send the required information to an analytics service in production.
## Browser support and fallback strategies
Long animation frames has limited availability.
Supported by: Chrome 123 (Mar 2024) and Edge 123 (Mar 2024).
Unsupported in: Firefox and Safari..
The Long Animation Frames API is ignored by browsers that do not support it, so it can be safely used without fallbacks. In most cases the performance opportunities it identifies will apply to other browsers as well.
guides/performance/identify-inp-causes.md
# Identify causes of poor INP
Poor responsiveness to interactions leads to a poor impression of a page being slow or even completely broken. Interaction to Next Paint (INP) is a metric based on the Event Timing API. It measures the worst interaction (minus some outliers) as a measure of the page's responsiveness.
Identifying root causes of an unresponsive web page can be tricky especially as it depends on user interactions and environmental conditions such as device capabilities and network conditions. This makes it even more difficult to diagnose compared to a more repeatable and predictable scenario like page load. Lab data only replicates a small subset of real user scenarios so measuring the causes of slow INP in the field is essential.
The Event Timing API allows for splitting the INP duration into three subparts: Input Delay (processing that is already happing when the interaction happens), Processing Duration (delays due as a direct result of the interaction), and Presentation Delay (delays that are due to rendering the next frame after the interaction). This helps identify if the issue is in other code, the interaction JavaScript code, or browser processing rendering updates respectively rather than jumping straight to the interaction code.
It is possible to gather further insights for JavaScript code delaying an interaction. A full performance trace using the JS Self-Profiling API is a heavyweight solution that is liable to cause performance problems. The Long Animation Frames API is a lightweight API that can be used to identify slow running JavaScript in the field for INP interactions.
## How to implement
Calculating INP from the raw Event Timing API is complicated and has several nuances. You are advised to use a RUM tool or library to gather this data.
`web-vitals` is an open-source library from Google that calculates the Core Web Vitals including INP, and also includes subparts and long animation frame data for the INP interaction.
## Get attributions for INP interactions using web-vitals library
The `web-vitals` library is a tiny library used to measure Core Web Vitals and other performance metrics. The `onINP()` function can be used to identify the slowest interaction and includes information about the INP subparts and scripts that were executed during the interaction using the Long Animation Frames API.
```javascript
// Use the attribution build to get Long Animation Frame data
// alongside the INP metric value.
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
// Beacon script attribution for the longest script during the INP
// interaction, so you can identify the root cause in production.
navigator.sendBeacon(
'/analytics',
JSON.stringify({
name: 'INP',
value: metric.value,
// These fields give the INP subparts:
inputDelay: metric.attribution.inputDelay,
presentationDelay: metric.attribution.presentationDelay,
processingDuration: metric.attribution.processingDuration,
interactionTarget: metric.attribution.interactionTarget,
// These fields identify which script function was responsible
// for the longest processing during the INP interaction.
invokerType: metric.attribution.longestScript.entry?.invokerType,
sourceURL: metric.attribution.longestScript.entry?.sourceURL,
sourceFunctionName: metric.attribution.longestScript.entry?.sourceFunctionName,
sourceCharPosition: metric.attribution.longestScript.entry?.sourceCharPosition,
// subpart indicates which phase (input delay, processing, or
// presentation delay) the longest script overlapped with most.
subpart: metric.attribution.longestScript.subpart,
intersectingDuration: metric.attribution.longestScript.intersectingDuration
})
);
});
```
## Best Practices
- **DO** use INP subparts initially to identify whether the delay is already running JavaScript (input delay), the event handlers JavaScript for the interaction (processing duration), or the subsequent rendering (presentation delay).
- **DO** attempt to identify the biggest blocking JavaScript as additional detail, particularly for input delay, and processing duration.
- **DO** prefer the Long Animation Frames API for providing this further detail over alternatives like the JS Self-Profiling API, which carries higher runtime overhead.
- **DO** use the `web-vitals` library if no other RUM solution is in place. It can identify the INP interaction and includes information about the subparts and the scripts that were executed during the interaction (using the Long Animation Frames API).
- **DO** beacon back the required information to an analytics service rather than just log it locally.
## Browser support and fallback strategies
Baseline status for Event timing: Newly available. It's been Baseline since 2025-12-12.
Supported by: Chrome 76 (Jul 2019), Edge 79 (Jan 2020), Firefox 89 (Jun 2021), and Safari 26.2 (Dec 2025).
The Event Timing API is available in most modern browsers and is necessary to calculate INP and the INP subparts. For browsers that do not support this API INP cannot be measured.
Long animation frames has limited availability.
Supported by: Chrome 123 (Mar 2024) and Edge 123 (Mar 2024).
Unsupported in: Firefox and Safari.
The Long Animation Frames API provides optional, additional details. It can be safely used without fallbacks. In most cases the performance opportunities it identifies will apply to other browsers as well.
guides/performance/improve-next-page-load-performance.md
# Improve next page load performance
One of the most effective ways to improve page load performance for users navigating a site is to initiate loading the next page they're about to visit *before* they visit it. This can be done through a technique called speculative loading using the Speculation Rules API.
## How it works
Speculative loading works by using JSON-based speculation rules to tell the browser about links that can be prefetched or prerendered improving page load performance when user clicks on them.
The rules can either be a hardcoded list of URLs a `urls` key (known as a list rule), or with a `where` key containing a set of href and CSS selectors used to find links on the page (known as a `document` rule).
Rules can also include an optional `eagerness` property that specifies when the page should be prefetched or prerendered. The `eagerness` property can be set to `immediate`, `eager`, `moderate`, or `conservative`. `immediate` speculates as soon as possible, while the others wait for user signals such as hovering for a short period, for a longer period, or starting to click on the page respectively.
Rules can be combined with different eagerness settings to prefetch eagerly and then prerender when the user interacts more.
## When to use it
Speculative loading is especially useful for static pages, where the content is not likely to change often, and where pages are cheaper to produce—especially if cached at the edge. It can also be used for dynamic pages, but it is important to be careful about the potential for stale content.
Speculative loading is typically used for same-origin links, though more advanced options allow for some cross-origin speculation. This guide concentrates on the more-common same-origin use case.
More eager speculative loading is a good choice for pages that are likely to be visited next, such as a headline article or the next page in a stepped process like a learning a course.
Less eager speculative loading is a good choice when it is less obvious what the user will do next, when there are many links on the page, each equally likely to be visited. By waiting for more signals, such as hovering, or starting to click on the page, you can get a head-start on the next page and improve the user experience, even if it is not fully prefetched or prerendered.
Similarly, prefetch is a more conservative choice than prerender, using less resources (on both the client and the server side) but providing less benefit to the user. It is a good choice for initial implementation, expanding to prerender later when the developer explicitly requests it.
## How to use it
Speculation rules have a JSON-based format and can be included in a `<script type="speculationrules">` tag. The rules can be included in the `<head>` or `<body>` of the document, or can be dynamically added using JavaScript.
A `tag` can also be used, either at a global level or on a per-rule basis. When set, this tag will be included in the `Sec-Speculation-Tags` header, and allows you to identify server-side which speculations were made.
### Example of a simple URL list rule for prefetching predefined URLs
```html
<script type="speculationrules">
{
"tag": "product-page-speculations",
"prefetch": [
{
"urls": ["/product/1", "/product/2", "/product/3"]
}
]
}
</script>
```
### Example of a simple document rule for prerendering all same-origin links on a page
```html
<script type="speculationrules">
{
"tag": "all-links-speculations",
"prerender": [{
"where": { "href_matches": "/*" }
}]
}
</script>
```
### Example of a complex document rule for prerendering links with exclusions for interactive sites
```html
<script type="speculationrules">
{
"tag": "speculations-with-exclusions",
"prerender": [{
"where": {
"and": [
{ "href_matches": "/*" },
{ "not": {"href_matches": "/wp-admin"}},
{ "not": {"href_matches": "/*\\?*(^|&)add-to-cart=*"}},
{ "not": {"selector_matches": ".do-not-prerender"}},
{ "not": {"selector_matches": "[rel~=nofollow]"}}
]
}
}]
}
</script>
```
### Example of a mixed rule set
This example shows a rule set that prefetches all links eagerly, and then goes further than this to prerender those same links when it gets more signals with `moderate` eagerness.
```html
<script type="speculationrules">
{
"prefetch": [{
"tag": "prefetch-speculations",
"where": { "href_matches": "/*" },
"eagerness": "eager"
}],
"prerender": [{
"tag": "prerender-speculations",
"where": { "href_matches": "/*" },
"eagerness": "moderate"
}]
}
</script>
```
## Best Practices
- **DO** use speculation rules to prefetch and prerender pages that the user is likely to visit next.
- **DO** use speculation rules for static sites, where the content is not likely to change often, and where pages are cheaper to produce—especially if cached at the edge.
- **DO** take more care when using speculation rules for dynamic pages, where the content is more likely to change often, may become out of date, and where pages are more expensive to produce.
- **DO** prefer document rules over list rules, as they are more flexible, allow the same rule to be shared across multiple pages, and can be used to prefetch and prerender pages that are not known in advance.
- **DO** consider the trade-offs between prefetch and prerender, and choose the appropriate one for your use case. Prerender is more expensive than prefetch and can cause more unintended side effects in complex applications that display dynamic state, but provides a better user experience. Ask the developer for their preference if unsure.
- **DO** consider the trade-offs between the different `eagerness` levels, and choose the appropriate one for your use case. More eager speculation provides a better user experience but uses more resources and can cause more unintended side effects in complex applications that display dynamic state. Ask the developer for their preference if unsure.
- **DO NOT** overuse speculation rules, for example, to speculate every link on the page. Browsers have limits (2 speculations for non-eager speculations). `immediate` should only be used for a very small number of links.
- **DO NOT** speculate URLs that likely trigger state changes, like `/logout` or `/add-to-cart`, and explicitly exclude them from your speculation rules if they are likely to be included in document rules.
- **DO NOT** use speculation rules on Single Page Applications (SPAs). Speculation rules are designed for multi-page applications (MPAs) where the browser navigates to a new document on each navigation. In SPAs, the browser does not navigate to a new document on each navigation, so speculation rules will not work as expected.
## Browser support and fallback strategies
Speculation rules has limited availability.
Supported by: Chrome 109 (Jan 2023) and Edge 109 (Jan 2023).
Unsupported in: Firefox and Safari.
However, speculative loading is a progressive enhancement. It is perfectly safe to use as an enhancement, and is highly recommended given the potential performance benefits. If a browser does not support speculation rules, it will simply ignore them.
guides/performance/interactions-in-complex-layouts.md
# Optimizing Interactions in Complex Layouts
Maintain high frame rates (60FPS) and eliminate interaction latency during drag-and-drop or heavy mutations in complex, multi-column layouts like Kanban boards or massive data grids.
## Overview
In complex layouts, performing a minor change to a single item—such as dragging a card or editing a cell—can trigger a chain reaction of style and layout calculations that forces the browser to reflow the entire page. This results in dropped frames and high Interaction to Next Paint (INP) latency.
By applying `content-visibility: auto` to self-contained layout regions (like columns in a Kanban board), you can isolate rendering work.
### Mechanism for On-Screen Elements
It is important to understand how `content-visibility: auto` benefits elements that are **already visible on the screen**:
* For visible elements, the browser **does not** skip rendering.
* Instead, the performance benefit comes entirely from the **CSS containments** that the property automatically enforces (i.e., layout, style, and paint).
* This containment acts as a boundary. If a mutation occurs inside a container with containment applied, the browser knows that the changes cannot affect the geometry or styles of elements outside that container. The page reflow is isolated, preventing a global layout recalculation.
## Implementation
### 1. Identify Containment Regions
Apply `content-visibility: auto` to large, self-contained containers that represent isolated layout units (e.g., grid columns, board lists).
```css
.board-column {
/* Apply containment boundaries */
content-visibility: auto;
/* Mandatory: Provide a placeholder size to prevent layouts shifts.
For a vertical column, define a reasonable width and height.
- 'auto' is optional and enables the browser to remember the actual size
once rendered. It must be paired with a <length> value to be used for
the first render.
- '300px' is the estimated width of this element. This can be any valid
CSS <length> value. Replace it with the expected width of your
component.
- '800px' is the estimated height of this element. This can be any valid
CSS <length> value. Replace it with the expected height of your
component.
*/
contain-intrinsic-size: auto 300px auto 800px;
}
```
### 2. Manage Interactions
Ensure that interactions occurring inside the column benefit from the containment.
```javascript
// Example: Drag and drop item movement
function moveItemToColumn(itemId, columnId) {
const item = document.getElementById(itemId);
const column = document.getElementById(columnId);
// The browser will only reflow this specific column,
// not the entire board layout!
column.appendChild(item);
}
```
### Fallback strategies
Baseline status for content-visibility: Newly available. It's been Baseline since 2025-09-15.
Supported by: Chrome 108 (Nov 2022), Edge 108 (Dec 2022), Firefox 130 (Sep 2024), and Safari 26 (Sep 2025).
The property degrades gracefully. In unsupported browsers:
* The property is ignored, and mutations will cause the standard global reflow.
* To achieve a similar isolation effect in older browsers, you can fall back to applying containment manually:
```css
@supports not (content-visibility: auto) {
.board-column {
/* Manual fallback for containment */
contain: layout style paint;
}
}
```
guides/performance/optimize-image-priority.md
# Optimize image priority
Browsers use heuristics to assign loading priorities to images, but these defaults may not always align with your page's Largest Contentful Paint (LCP).
Using `fetchpriority` on an `<img>` element allows you to explicitly signal an image's importance to the browser, ensuring critical images load faster while non-essential ones don't compete for bandwidth.
The `loading=lazy` attribute prevents images from being downloaded at all when sufficiently off-screen which can further help prioritize images.
## How to implement
1. **Identify the LCP image**: Determine which image is the most likely candidate for the Largest Contentful Paint (usually the hero image at the top of the page).
2. **Elevate LCP priority**: Add `fetchpriority="high"` to the `<img>` element for the LCP candidate.
3. **Deprioritize non-critical images**: For images that are part of a secondary UI or are only revealed after user interaction (like mega menus, modals, or off-screen carousel slides), add `fetchpriority="low"`.
4. **Optimize lazy loading**: Never use `loading="lazy"` on the LCP image. For standard below-the-fold images, `loading="lazy"` is sufficient to defer the request until the user scrolls near them. Avoid adding `fetchpriority="low"` to these images, as you want them to load at normal priority once the user scrolls to them. Reserve `fetchpriority="low"` for images that are technically "above the fold" but not initially visible (e.g., hidden carousel slides or mega menus). For these hidden images, it is acceptable to use `loading="lazy"` as well; the browser will handle the request timing while respecting the low priority.
5. **Prefer default priorities**: If an image should have normal loading priority, omit the `fetchpriority` attribute entirely rather than setting it to `auto`. This is a stylistic convention to keep your HTML cleaner while relying on the browser's native heuristics.
## Example code
```html
<!-- Elevate priority for the LCP image -->
<img src="/images/hero-lcp.jpg"
alt="Main Banner"
fetchpriority="high"
width="800" height="400">
<!-- Deprioritize initially hidden images above the fold -->
<!-- Additionally use `loading="lazy"` if there are likely to be many images-->
<div class="carousel">
<img src="/images/gallery-alt.jpg"
alt="Gallery Image 1"
width="400" height="300">
<img src="/images/gallery-alt.jpg"
alt="Gallery Image 2"
fetchpriority="low"
loading="lazy"
width="400" height="300">
<div>
<!-- Deprioritize images revealed only after user interaction -->
<img src="/images/mega-menu-promo.jpg"
alt="Special Offer"
fetchpriority="low"
width="300" height="150">
<!-- Use lazy loading ALONE for standard below-the-fold images -->
<img src="/images/footer-logo.png"
alt="Footer Logo"
loading="lazy"
width="120" height="60">
<!-- Omit fetchpriority for images with standard priority -->
<img src="/images/standard-image.jpg"
alt="Standard Image"
width="400" height="300">
```
## Best practices
- **MANDATORY**: Always apply `fetchpriority="high"` to the LCP image.
- **MANDATORY**: Only use `fetchpriority="high"` on at most 1-2 critical images to avoid network contention and diluting the priority boost.
- **MANDATORY**: Use `fetchpriority="low"` for images that are technically "above the fold" but initially hidden (e.g., hidden carousel slides, mega menu images).
- **MANDATORY**: **Do not** use `fetchpriority="low"` on standard below-the-fold images that are already using `loading="lazy"`. These images should load at normal priority once they enter the viewport.
- **RECOMMENDED**: Avoid using `fetchpriority="auto"`. If you want the default priority, omit the attribute entirely to keep your HTML clean.
- **DO NOT** combine `fetchpriority="high"` with `loading="lazy"`.
- **DO NOT** use the deprecated `importance` attribute. It has been replaced by `fetchpriority` and is not supported by any browser.
- **DO NOT** use the deprecated `loading="auto"` value. It was initially supported by Chrome but has been removed and is not supported by any browser.
## Fallback strategy
Baseline status for Fetch priority: Newly available. It's been Baseline since 2024-10-29.
Supported by: Chrome 103 (Jun 2022), Edge 103 (Jun 2022), Firefox 132 (Oct 2024), and Safari 17.2 (Dec 2023).
The `fetchpriority` attribute is a progressive enhancement for the `<img>` element. If a browser does not support it, the attribute is ignored, and the browser uses its default priority heuristics.
guides/performance/optimize-preload-priority.md
# Optimize preload priority
Preloading resources with `<link rel="preload">` signals to the browser that a resource will be needed soon. However, preloads inherit the default priority for the resource type and for images in particular this is low. Using `fetchpriority` allows you to refine this relative priority, in particular ensuring image preloads are preloaded with a high priority allowing them to start earlier and use get more bandwidth resources.
## How to implement
1. **Identify preload candidates**: Find resources that are not discovered early by the browser (e.g., video poster images or background images in CSS) but are essential for the page's appearance.
2. **Elevate critical image preloads**: For the LCP image or other critical images that are not prioritized by default, use `<link rel="preload" fetchpriority="high">` to ensure they are prioritized above other preloads. Note that fonts are already high priority by default.
3. **Deprioritize non-critical preloads**: For resources that aren't critical for the initial render (e.g., a background video or secondary fonts), use `<link rel="preload" fetchpriority="low">`.
4. **Coordinate with resource types**: Note that different `as` types have different default priorities; use `fetchpriority` to override these defaults when necessary.
## Example code
```html
<!-- Elevate priority for a video poster image that acts as the LCP candidate -->
<link rel="preload" href="/images/video-poster.jpg" as="image" fetchpriority="high">
<!-- Elevate priority for a critical LCP image that is hidden in CSS -->
<link rel="preload" href="/images/hero-background.jpg" as="image" fetchpriority="high">
<!-- Deprioritize a secondary font to avoid network contention -->
<link rel="preload" href="/fonts/secondary-font.woff2" as="font" type="font/woff2" fetchpriority="low" crossorigin>
```
## Best Practices
- **MANDATORY**: Only use `fetchpriority="high"` on at most 1-2 critical image preloads to avoid network contention and diluting the priority boost.
- **MANDATORY**: Use `fetchpriority="high"` on Largest Contentful Paint (LCP) images.
- **DO**: Limit the total number of preloads on a page to at most 2 images and 2-3 essential fonts to prevent bandwidth contention.
- **DO** use `fetchpriority="low"` for preloads that you want the browser to start early but not at the expense of critical resources (especially non-critical fonts).
- **DO** specify the `as` attribute correctly to ensure the preload will be used.
- **DO** prefer making critical resources (like LCP images) statically discoverable in HTML via `<img>` tags rather than relying on preloads for background images.
- **DO** ensure that font preloads always have the `crossorigin` attribute (even if same-origin).
- **DO NOT** use the deprecated `importance` attribute. It has been replaced by `fetchpriority`.
## Fallback strategy
Baseline status for Fetch priority: Newly available. It's been Baseline since 2024-10-29.
Supported by: Chrome 103 (Jun 2022), Edge 103 (Jun 2022), Firefox 132 (Oct 2024), and Safari 17.2 (Dec 2023).
The `fetchpriority` attribute on `<link rel="preload">` is a progressive enhancement. Browsers that do not support it will still preload the resource using their default priority for that resource type. To ensure compatibility, always provide correct `as` and `type` attributes.
guides/performance/optimize-script-priority.md
# Optimize script priority
Browsers assign default priorities to scripts based on where they appear in the document and whether they have attributes like `async` or `defer`. Using `fetchpriority` gives developers explicit control to ensure critical scripts load first, while non-essential scripts stay out of the way.
## How to implement
1. **Identify critical scripts**: Determine which scripts are essential for the page's core functionality or initial user interaction.
2. **Elevate critical async scripts**: For critical scripts loaded with `async` or `defer`, add the `fetchpriority="high"` attribute to ensure they are prioritized during the discovery phase.
3. **Deprioritize non-essential scripts**: For scripts that are not needed immediately (e.g., analytics, ads, or below-the-fold widgets), add `fetchpriority="low"` and ensure they have a `async`, `defer`, or `module` attribute to avoid blocking.
4. **Sequence parser-blocking scripts**: Use `fetchpriority="low"` on parser-blocking scripts at the end of the body to prevent them from contending for bandwidth with more critical resources.
## Example code
```html
<!-- Elevate the priority of the critical app logic -->
<script src="/js/app.js" async fetchpriority="high"></script>
<!-- Deprioritize non-essential tracking scripts -->
<script src="/js/tracker.js" async fetchpriority="low"></script>
<!-- Deprioritize late-body scripts to favor critical images or CSS -->
<script src="/js/legacy-widgets.js" fetchpriority="low"></script>
```
## Best Practices
- **MANDATORY**: Only use `fetchpriority="high"` on at most 1-2 critical scripts to avoid network contention and diluting the priority boost.
- **DO** use `fetchpriority="high"` specifically for `async` scripts that are known to be critical for Interaction to Next Paint (INP).
- **DO** deprioritize scripts that are not required for the initial user experience using `fetchpriority="low"`.
- **DO NOT** use `fetchpriority` on every script tag; it should only be used to change the browser's default heuristic when it is known to be sub-optimal.
- **DO NOT** use the deprecated `importance` attribute. It has been replaced by `fetchpriority`.
## Fallback strategy
Baseline status for Fetch priority: Newly available. It's been Baseline since 2024-10-29.
Supported by: Chrome 103 (Jun 2022), Edge 103 (Jun 2022), Firefox 132 (Oct 2024), and Safari 17.2 (Dec 2023).
The `fetchpriority` attribute is a progressive enhancement. Browsers that do not support it will ignore the attribute and use their internal scheduling logic without error. No explicit feature detection or fallback logic is required for basic usage.
guides/performance/performance.md
# Performance
## Critical Rendering Path (CRP) Optimization
The Critical Rendering Path dictates how quickly the browser converts HTML, CSS, and JavaScript into painted pixels.
### DOs
* **DO inline critical CSS**: Extract styles necessary for above-the-fold content and inject them directly into the HTML `<head>`. Defer the rest of the stylesheet.
* **DO use `async` or `defer` for all non-critical scripts**: Prevent JavaScript from blocking the DOM parser. Use `defer` for scripts that depend on the DOM or each other, and `async` for independent scripts. `type="module"` is preferred for modern JavaScript and is deferred by default so no need to have an explicit `defer` attribute but you can use `async` on independent module scripts.
* **DO split CSS by media queries**: Use the `media` attribute on `<link>` tags so the browser downloads unused stylesheets (e.g., print styles or desktop styles on mobile) without blocking the render.
* **DO utilize resource hints**: Add `preconnect` or `dns-prefetch` for essential third-party domains (e.g., font foundries or API endpoints) to establish early TLS handshakes.
### DON'Ts
* **DON'T use `@import` in CSS**: This creates sequential request chains that delay the CSS Object Model (CSSOM) construction.
* **DON'T place large, non-critical JavaScript in the `<head>`**: This halts DOM construction until the script is downloaded, parsed, and executed.
* **DON'T load invisible or unreachable CSS/JS**: Ensure build tools apply tree-shaking and CSS minification to drop unreachable code before deployment.
### Code Examples
**HTML: Deferring Non-Critical CSS & Scripts**
```html
<!-- Inline critical styles directly in head -->
<style>
body { margin: 0; font-family: system-ui; }
.hero { min-height: 100vh; }
</style>
<!-- Defer non-critical CSS -->
<link rel="preload" href="non-critical.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="non-critical.css"></noscript>
<!-- Load CSS conditionally based on viewport -->
<link rel="stylesheet" href="mobile.css" media="(max-width: 768px)">
<!-- Defer JavaScript execution -->
<script defer src="app-bundle.js"></script>
```
### The Resource Hint Navigator
| Hint | Tool Use Case | Example |
| :--- | :--- | :--- |
| `preconnect` | Resolve TLS/DNS for known third-party APIs | API endpoints, font services |
| `dns-prefetch` | Lean fallback for non-critical third-party origins | Ad servers, analytics fallbacks |
| `preload` | Same-origin asset needed *now* for rendering | Hero images, render-blocking fonts |
| `prefetch` | Assets needed for next-page navigation | Next-page bundle, detail views |
**Single-Sentence Mental Model**: "Preconnect for domains, Preload for viewport, Prefetch for futures."
## Largest Contentful Paint (LCP) & Resource Fetching
LCP measures the time required to render the largest visible text or image block within the viewport. Optimize LCP by prioritizing visible elements and prepolishing.
### DOs
* **DO declare the LCP image in standard HTML**: Ensure LCP images are present in the raw HTML response so the preload scanner discovers it immediately. This can be be via an `<img>` element (preferred) or a `<link rel="preload" as="image">` (where the image resource is loaded due to JavaScript or CSS). Avoid relying on JavaScript or CSS to be the only source of the image resource.
* **DO use `fetchpriority="high"` on the LCP images**: Images are not downloaded initially while the browser prioritizes render-blocking resources like CSS and JavaScript and can perform layout to discover if the images are in the viewport or not. Use `fetchpriority="high"` to signal to the browser's heuristic engine to start downloading the LCP image earlier. This should be on the `<img>` element (`<img fetchpriority="high">`) and any preload (`<link rel="preload" as="image" fetchpriority="high">`).
* **DO use `fetchpriority="low"` to demote competing elements that are not in the initial viewport**: Lower the priority of large images or carousels that are *not* the primary LCP element and do *not* appear in the initial viewport, but may still be downloaded if near the viewport or hidden with CSS (for example `overflow:scroll` for carousels). Images with `fetchpriority="low"` will still be downloaded but start after higher-priority resources the browser has queued.
* **DO use `loading="lazy"` to avoid loading competing elements that are far out of the initial viewport**: Images, iframes, and video and audio media which are well outside of the viewport should be lazy-loaded to avoid fetching these at all. Images with `loading="lazy"` will not be downloaded at all when well outside the viewport but will download when in, or near, the viewport (depending on browser heuristics and connection settings),.
### DON'Ts
* **DON'T lazy-load the LCP image**: Never apply `loading="lazy"` to above-the-fold images. This purposefully delays the fetch until layout calculation is complete, severely degrading LCP.
* **DON'T overuse `fetchpriority="high"`**: Prioritization is a zero-sum mechanism. Elevating too many resources creates network contention and negates the benefit.
* **DON'T implement complex JavaScript loaders for the hero section**: Client-side rendering of the LCP element introduces substantial request chains (HTML -> JS -> Execution -> Image Request).
### Code Examples
**HTML: LCP Image Optimization**
```html
<!-- Standard LCP Image -->
<img
src="/images/hero.webp"
alt="Hero Product"
fetchpriority="high"
width="1200"
height="600"
>
<!-- Preloading a CSS-based LCP background -->
<link rel="preload" as="image" href="/images/bg-hero.webp" fetchpriority="high" type="image/webp">
<!-- Demoting an above-the-fold non-LCP carousel image -->
<img src="/images/carousel-2.webp" fetchpriority="low" loading="lazy" alt="Slide 2">
```
## Interaction to Next Paint (INP) & Main Thread Unblocking
INP measures the latency of all interactive events across the page's lifecycle. Poor INP is caused by long-running JavaScript tasks blocking the main thread.
### DOs
* **DO break up long tasks**: Any JavaScript execution exceeding 50ms should be split. Yield to the main thread frequently so the browser can process pending user inputs.
* **DO use `scheduler.yield()` with a fallback**: Utilize the modern `scheduler.yield()` API to place task continuations at the *front* of the queue, falling back to `setTimeout` wrapped in a Promise for unsupported browsers.
* **DO debounce or throttle rapid event listeners**: Limit the execution frequency of handlers attached to `scroll`, `resize`, or rapid `input` events.
* **DO separate UI updates from heavy computations**: Update the UI synchronously to provide immediate visual feedback, then push background processing to a Web Worker or deferred task.
### DON'Ts
* **DON'T rely solely on `setTimeout(..., 0)` for continuous yielding**: Standard `setTimeout` places continuations at the *back* of the task queue, potentially causing long delays if other tasks are pending. Use `scheduler.yield()` where available.
* **DON'T cause layout thrashing**: Avoid interleaving DOM reads (`offsetHeight`, `getBoundingClientRect`) and writes (`style.height`) within the same loop. Batch DOM reads, then batch DOM writes.
* **DON'T block the thread with recurring timers**: Avoid heavy polling with `setInterval` that starves the main thread.
### Code Examples
**JS: `scheduler.yield` Polyfill and Usage**
```javascript
// Polyfill for yielding to main thread
async function yieldToMain() {
if ('scheduler' in window && 'yield' in scheduler) {
return await scheduler.yield();
}
return new Promise(resolve => setTimeout(resolve, 0));
}
// Processing a large array without blocking user input
async function processLargeList(items) {
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
// Yield every 50 iterations to allow rendering/interaction
if (i % 50 === 0) {
await yieldToMain();
}
}
}
```
### Main Thread Task Slicing Heuristic
**The 50ms Rule for INP**:
- **< 50ms**: Execute synchronously.
- **50ms - 250ms**: Slice tasks and yield with `scheduler.yield()`.
- **> 250ms**: Offload to a Web Worker.
## Third-Party Script Management
Third-party scripts (analytics, ads, chat widgets) are the primary source of main thread congestion.
### DOs
* **DO avoid third-party scripts blocking main content**: Use `defer` with all third-party scripts unless critical to the page load and load them in the footer of the page, rather than the `<head>`.
* **DO self-host critical third-party dependencies**: Reduce DNS lookups and enforce custom `Cache-Control` logic by hosting third-party libraries on the origin domain.
### Code Examples
**HTML: Third-Party Script Execution**
```html
<!-- 1. Place third-party scripts near the end of the page with the defer attribute -->
<script defer src="http://www.example.com/third-party.js"></script>
```
## CSS Rendering & Containment Optimization
Rendering involves Layout, Style, Paint, and Compositing calculations. CSS Containment limits the scope of these calculations which is useful on large, complex pages where such calculations can cause performance problems.
### DOs
* **DO use `content-visibility: auto` on off-screen sections on large, complex pages**: Instruct the browser to skip layout and paint calculations for entire subtrees until they approach the viewport.
* **DO pair `content-visibility` with `contain-intrinsic-size`**: Prevent layout shifts and scrollbar jumping by providing a placeholder height/width for unrendered containers.
* **DO apply explicit CSS containment (`contain`)**: For isolated UI components (like modals or widgets), use `contain: layout style paint` to prevent internal changes from triggering page-wide reflows.
### DON'Ts
* **DON'T apply `content-visibility: auto` on smaller, simpler pages**: The gains will be negligible and there are risks of side effects with content jumping.
* **DON'T apply `content-visibility: auto` to above-the-fold content**: The browser will still evaluate it, but forcing it through the containment engine unnecessarily adds slight overhead to visible elements.
* **DON'T overuse `will-change` globally**: Indiscriminately applying `will-change: transform` to multiple elements consumes excessive VRAM, causing GPU crashes or sluggish rendering.
* **DON'T forget accessibility when hiding elements**: `content-visibility: auto` keeps elements in the DOM for screen readers. If content should be truly hidden from assistive technology when off-screen, manage `aria-hidden` attributes manually.
### Code Examples
**CSS: Content Visibility and Containment**
```css
/* Optimize a long list of articles below the fold */
.article-list-item {
content-visibility: auto;
contain-intrinsic-size: auto 600px; /* Provides a 600px placeholder */
}
/* Scope a complex widget to prevent layout thrashing */
.isolated-widget {
contain: layout style paint;
}
/* Hardware accelerate an animation only on hover */
.interactive-button:hover {
will-change: transform;
transform: scale(1.05);
}
```
## Modern Image & Media Optimization
Images typically represent the largest payload on a given web page. Optimization requires format negotiation, responsive sizing, and layout stabilization.
### DOs
* **DO serve modern formats (AVIF / WebP)**: Use the `<picture>` element to offer AVIF (best compression), falling back to WebP, and finally JPEG/PNG for legacy browsers.
* **DO apply explicit `width` and `height` attributes**: Setting native attributes allows the browser to compute the aspect ratio immediately, reserving space and eliminating CLS. Image dimensions may be set either as HTML attributes or CSS properties.
* **DO utilize `loading="lazy"` on all below-the-fold images**: Utilize native browser lazy loading to defer network requests for images outside the initial viewport.
* **DO implement responsive images with `srcset` and `sizes`**: Serve tailored resolutions based on screen density and viewport width to prevent mobile devices from downloading desktop-sized images.
### DON'Ts
* **DON'T lazy load above-the-fold images**: This directly harms LCP. Visible images must use `loading="eager"` (the default).
* **DON'T delete necessary dimensions**: Failing to specify width/height on lazy loaded images causes layout shifts.
* **DON'T omit the `sizes` attribute when using `srcset`**: Without `sizes`, the browser assumes `100vw` and downloads the largest available image.
### Code Examples
**HTML: Comprehensive Responsive Image Component**
```html
<picture>
<!-- Modern Formats with Source Negotiation -->
<source type="image/avif" srcset="hero-400w.avif 400w, hero-800w.avif 800w" sizes="(max-width: 600px) 100vw, 50vw">
<source type="image/webp" srcset="hero-400w.webp 400w, hero-800w.webp 800w" sizes="(max-width: 600px) 100vw, 50vw">
<!-- Fallback + Dimensions + Priority for Above-The-Fold -->
<img
src="hero-800w.jpg"
alt="Descriptive text"
width="800"
height="600"
fetchpriority="high"
loading="eager"
>
</picture>
<!-- Below-The-Fold Image -->
<img
src="footer-icon.png"
alt="Footer Logo"
width="100"
height="100"
loading="lazy"
>
<!-- DO: Use native lazy loading for below the fold iframes -->
<iframe src="https://example.com/map" width="800" height="600" loading="lazy" title="Example Map"></iframe>
```
## Service Workers & Caching Strategies
Client-side caching via Service Workers allows applications to bypass the network entirely, serving resources from disk/memory.
### DOs
* **DO use a `CacheFirst` strategy for static, versioned assets**: Immutable files (fonts, JS/CSS bundles with hash strings) should be served directly from the cache to guarantee instant loading.
* **DO use `StaleWhileRevalidate` for dynamic, non-critical resources**: For API calls where slight staleness is acceptable, serve immediately from cache while silently updating the cache in the background.
* **DO implement a `NetworkFirst` strategy for HTML documents**: Ensure the user always receives the latest application shell and manifest, falling back to cache only if offline.
* **DO restrict cache sizes and expiry**: Use expiration plugins to prevent the Service Worker from exhausting the device's storage quota.
### DON'Ts
* **DON'T cache opaque responses blindly**: Responses from third-party domains lacking CORS headers are "opaque". Caching them heavily consumes quota and fails silently. Only cache them using `NetworkFirst` or `StaleWhileRevalidate`.
* **DON'T cache POST requests**: Service workers cannot cache non-GET requests natively. Implement background sync queues for offline submissions.
* **DON'T bypass versioning**: Failing to update asset hashes/versions will trap users in infinite cache loops.
### Code Examples
**JS: Service Worker Caching via Workbox**
```javascript
import { registerRoute } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate, NetworkFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
// 1. HTML Documents: Network First
registerRoute(
({ request }) => request.mode === 'navigate',
new NetworkFirst({ cacheName: 'pages-cache' })
);
// 2. Static Assets (JS, CSS, Fonts): Cache First
registerRoute(
({ request }) => ['style', 'script', 'font'].includes(request.destination),
new CacheFirst({
cacheName: 'static-resources',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 })
]
})
);
// 3. API Responses: Stale While Revalidate
registerRoute(
({ url }) => url.pathname.startsWith('/api/v1/content'),
new StaleWhileRevalidate({
cacheName: 'api-cache',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] })
]
})
);
```
## Web Fonts Optimization
Web fonts are a common source of render blocking. Optimizing them reduces the Flash of Invisible Text (FOIT) and speeds up initial rendering.
### DOs
* **DO preload critical fonts**: Use `<link rel="preload" as="font" type="font/woff2" crossorigin>` for fonts seen above the fold. Do include the `crossorigin` attribute for all fonts (even same origin fonts).
* **DO subset fonts**: Trim font weights and glyph variations to include only the characters your application requires.
### DON'Ts
* **DON'T preload all fonts**: Over-preloading leads to network contention that starves other critical assets.
* **DON'T use `fetchpriority="high"` on fonts**: Fonts are loaded with a high priority by default so there is no need to specify `fetchpriority="high"`.
### Code Examples
**CSS: Font Loading Face**
```css
@font-face {
font-family: 'Modern Sans';
src: url('/fonts/modern-sans.woff2') format('woff2');
}
```
**HTML: Critical Font Preload**
```html
<!-- Always use crossorigin for fonts even if on the same origin -->
<link rel="preload" href="/fonts/modern-sans.woff2" as="font" type="font/woff2" crossorigin>
```
## Video Performance & Metrics
Video payloads are among the heaviest assets. Optimization focuses on reducing bandwidth stall and preserving Cumulative Layout Shift (CLS) stability.
### DOs
* **DO specify explicit `width` and `height` attributes**: Setting native dimensions reserves layout space and prevents CLS.
* **DO provide a `poster` image fallback**: Display a lightweight image placeholder while the video buffers to improve perceived performance.
* **DO use `<link rel="preload" as="image" fetchpriority="high">` for poster images where the video is the LCP element**: This ensures the image is downloaded as quickly as possible.
* **DO use `preload="none"` for non-critical videos**: Delay bandwidth consumption for below-the-fold or non-autoplaying videos.
* **DO serve modern formats via source negotiation**: Offer WebM (better compression ratio) alongside standard MP4 formats.
* **DO use `loading="lazy"` for offscreen videos**: Lazy-loading videos allow `poster` and `preload` downloads to be deferred until the video is in or near the viewport.
### DON'Ts
* **DON'T auto-play video files blindly**: Rely on user intent or use progressive enhancement streams.
* **DON'T auto-play large video files at all**: Rely on user intent before downloading large files.
### Code Examples
**HTML: Accessible and Dynamic Video Loader**
```html
<video
controls
width="1200"
height="675"
poster="/images/video-poster.webp"
preload="none"
loading="lazy"
>
<source src="/videos/intro.webm" type="video/webm">
<source src="/videos/intro.mp4" type="video/mp4">
<!-- Include accessibility tracks -->
<track src="/video-caps.vtt" kind="captions" srclang="en" label="English">
</video>
```
## JavaScript Code-Splitting
Heavy monolithic bundles block main thread parse times on low-end devices. Splitting ensures we only download bytes required for the immediate viewport.
### DOs
* **DO use dynamic imports**: Split routes or heavy UI libraries using standard `import()` specifications.
* **DO configure bundler asset chunking**: Use Vite or Webpack rollup directives to split third-party vendors from runtime application logic.
### DON'Ts
* **DON'T ship a single, enormous `app.js` bundle**: It increases parse time and memory consumption for initial views.
### Code Examples
**JS: Route based Dynamic Splitting**
```javascript
// Dynamic import of heavy module only when button is clicked
document.getElementById('heavy-btn').addEventListener('click', async () => {
const { heavyFunction } = await import('./heavy-module.js');
heavyFunction();
});
```
guides/performance/resolution-optimized-pseudo-elements.md
# Resolution Optimized Pseudo Elements
Using resolution-optimized images in CSS pseudo-elements (like `::before` or `::after`) allows you to add decorative icons or structural graphics without cluttering your HTML with extra DOM nodes. By combining pseudo-elements with the `image-set()` CSS function, you can provide the browser with multiple formats (such as AVIF or WebP) and resolutions (like `1x` and `2x`). The browser will automatically choose the most optimal image for the user's device capabilities.
### Implementation
You can use the `image-set()` function directly within the `content` property of a pseudo-element, or within its `background-image` property (while setting `content: ""`). Note that while providing both the image format via `type()` and the resolution (like `1x` or `2x`) yields the best results, both of these arguments are optional.
```css
.icon-button::before {
/* Using image-set directly in the content property */
/* MANDATORY: Always order your formats from most optimized (AVIF) to least optimized (JPEG/PNG).
The browser will stop at the first supported format. */
content: image-set(
url("icon.avif") type("image/avif") 1x,
url("icon-2x.avif") type("image/avif") 2x,
url("icon.webp") type("image/webp") 1x,
url("icon-2x.webp") type("image/webp") 2x,
url("icon.png") type("image/png") 1x,
url("icon-2x.png") type("image/png") 2x
);
display: inline-block;
margin-right: 8px;
vertical-align: middle;
}
```
### Fallback strategies
Baseline status for image-set(): Widely available. It's been Baseline since 2023-09-18.
Supported by: Chrome 113 (May 2023), Edge 113 (May 2023), Firefox 89 (Jun 2021), and Safari 17 (Sep 2023).
For older browsers that do not support the `image-set()` function, you **MUST** provide a standard image declaration *before* the `image-set()` rule. This progressive enhancement strategy relies on CSS's cascading nature: unsupported rules are ignored.
```css
.icon-button::before {
/* MANDATORY: Fallback for browsers that do not support image-set() */
content: url("icon.png");
/* Modern browsers will apply this and override the fallback */
content: image-set(
url("icon.avif") type("image/avif") 1x,
url("icon-2x.avif") type("image/avif") 2x,
url("icon.png") type("image/png") 1x,
url("icon-2x.png") type("image/png") 2x
);
}
```guides/performance/schedule-tasks-by-priority.md
# Schedule Tasks By Priority
When building complex web applications, tasks have different levels of urgency. Completing tasks for the current view is more important than sending analytics or prefetching assets. The Prioritized Task Scheduling API allows you to schedule work with specific priorities, ensuring the browser remains responsive to user input.
### Scheduling tasks by priority
Use `scheduler.postTask()` to schedule tasks with one of three priorities:
- `user-blocking`: Tasks that block user interaction (e.g., input handling, critical rendering).
- `user-visible`: Tasks visible to the user but not blocking (default).
- `background`: Tasks that are not time-critical (e.g., analytics, prefetching).
```javascript
// Schedule a high-priority task that blocks user interaction
scheduler.postTask(() => {
// DO: Handle critical updates that impact user interaction
handleCriticalUpdate();
}, { priority: 'user-blocking' });
// Schedule a default priority task
scheduler.postTask(() => {
// DO: Render non-critical content that is visible to the user
renderSecondaryContent();
}); // Defaults to 'user-visible'
// Schedule a low-priority background task
scheduler.postTask(() => {
// DO: Perform heavy background work that is not time-critical
sendAnalytics();
}, { priority: 'background' });
```
### Fallback strategies
Scheduler API has limited availability.
Supported by: Chrome 129 (Sep 2024), Edge 129 (Sep 2024), and Firefox 142 (Aug 2025).
Unsupported in: Safari.
To support browsers that do not have the Prioritized Task Scheduling API, you must use a polyfill to maintain task prioritization.
```javascript
// Feature detect the scheduler API
if (!('scheduler' in window && 'postTask' in window.scheduler)) {
// DO: Conditionally load the polyfill for browsers that need it
const script = document.createElement('script');
script.src = 'https://unpkg.com/scheduler-polyfill';
script.onload = () => {
// Polyfill is loaded and ready to use
runScheduledTasks();
};
document.head.appendChild(script);
} else {
runScheduledTasks();
}
function runScheduledTasks() {
// Now safe to use scheduler.postTask in all browsers
scheduler.postTask(() => {
console.log('Task with priority support');
}, { priority: 'background' });
}
```
guides/performance/sequence-distributed-events.md
# Sequencing Distributed Events
High-frequency tracing and event logging in distributed systems require precise timestamps to ensure correct causal ordering. Standard JavaScript `Date.now()` provides millisecond resolution, which can lead to timestamp collisions when multiple events occur within the same millisecond.
The `Temporal` API, specifically `Temporal.Instant`, provides nanosecond-resolution timestamps, enabling precise sequencing of events without collisions.
## How to Implement
To sequence high-frequency events using `Temporal`:
1. **Capture exact timestamps**: Use `Temporal.Now.instant()` to get the current exact time with nanosecond precision.
2. **Sort events chronologically**: Use `Temporal.Instant.compare(a, b)` to sort event objects. This method resolves ordering differences up to the nanosecond level.
3. **Calculate delays**: Use `Temporal.Instant.prototype.since(other)` to find the precise duration between events.
4. **Serialize for transmission**: Use `Temporal.Instant.prototype.toString()` to convert the timestamp to a standard ISO-8601 string for logging or network transmission.
## Example Code: High-Frequency Event Sequencing
```javascript
// 1. Capture timestamps for incoming events
function recordEvent(eventType, nodeId) {
return {
nodeId,
eventType,
timestamp: Temporal.Now.instant() // Nanosecond resolution
};
}
// 2. Sort events chronologically
function sequenceEvents(events) {
// Always use Temporal.Instant.compare for sorting instants
return [...events].sort((a, b) => Temporal.Instant.compare(a.timestamp, b.timestamp));
}
// 3. Calculate delays between events
function analyzeTelemetry(sortedEvents) {
for (let i = 1; i < sortedEvents.length; i++) {
const prev = sortedEvents[i - 1];
const curr = sortedEvents[i];
// Calculate difference in nanoseconds
const duration = curr.timestamp.since(prev.timestamp);
const nsDiff = duration.total('nanoseconds');
console.log(`Delay between Event ${prev.eventType} and Event ${curr.eventType}: ${nsDiff}ns`);
}
}
```
## Strategic Implementation & Best Practices
- **DO** use `Temporal.Now.instant()` for server-side tracing or client-side telemetry where millisecond precision is insufficient (e.g., microsecond profiling).
- **DO NOT** use `Date.now()` if you require stable sorting of events that happen back-to-back.
- **DO NOT** use `Temporal.Instant` for wall-clock time display unless you pair it with a time zone (use `Temporal.ZonedDateTime` for localized display).
- **DO** verify that the environment supports `Temporal` before using it natively or providing a fallback.
## Fallback strategies
Temporal has limited availability.
Supported by: Chrome 144 (Jan 2026), Edge 144 (Jan 2026), and Firefox 139 (May 2025).
Unsupported in: Safari.
For environments without native support, use a standards-compliant polyfill such as `@js-temporal/polyfill`. Load it conditionally to avoid bloating the payload for modern clients. Note that `@js-temporal/polyfill` does not automatically install a global `Temporal` object, so you must explicitly assign it if you need it globally.
```javascript
(async () => {
// Check for native support
if (typeof Temporal === 'undefined') {
// Dynamically load polyfill using an ESM-compatible CDN
const module = await import('https://esm.sh/@js-temporal/polyfill');
// The polyfill does not auto-install globally, so we must assign it
globalThis.Temporal = module.Temporal;
}
// Proceed with application logic
})();
```
guides/privacy/privacy.md
# Web Privacy Guidelines for Developers
Web application developers must treat privacy as a foundational architectural requirement, not just a legal compliance checkbox. As the web ecosystem shifts away from passive tracking toward explicit, user-consented interactions, building privacy-preserving applications is critical for user trust and security.
This document provides high-level principles and detailed, actionable guidelines with code examples for web developers.
## High-Level Overview
These core themes should guide your approach to privacy in web development:
1. **Automation Asymmetry & Privacy Labor**: Do not offload the burden of protecting privacy to the user (privacy labor). Avoid overwhelming users with complex consent dialogs (automation asymmetry). Users have limited time and attention; offloading privacy choices to them is often ineffective and causes fatigue. Systems should be privacy-protective by default.
2. **Data Minimization**: "If you don't have the data, you can't lose it." Collect only the bare minimum required for the immediate task. Reducing data storage reduces the risk of breach and builds user trust.
3. **Purpose Limitation**: Data collected for one purpose must not be used for another without fresh consent. Repurposing data without explicit agreement violates the trust relationship with the user.
4. **Transparency by Default**: Be honest and clear about why data is collected, where it goes, and how long it is kept. Transparency builds trust and can be a unique selling point for your application.
5. **Trustworthy Agency**: Treat your application as an agent acting in the user's best interest. This means protecting them from intrusive behaviors, unnecessary data exposure, and acting as a loyal fiduciary to the user rather than serving third-party interests.
## Detailed Guidelines
### 1. Data Minimization and Purpose Limitation
Reducing the amount of data collected and strictly limiting its use is the most effective way to protect user privacy.
#### DOs:
* **DO** collect data at the lowest granularity necessary. If you only need to know if a user is in a certain age bracket (e.g., 18-34), ask for the bracket, not the exact date of birth.
* **DO** provide guest checkout options for e-commerce to avoid forced account creation, which reduces data collection and cart abandonment.
* **DO** delete data as soon as the purpose for its collection has been fulfilled.
* **DO** use techniques like "fuzzing" or adding noise to data (Differential Privacy) when gathering aggregate statistics.
#### DON'Ts:
* **DON'T** collect data speculatively "just in case" it might be useful in the future.
* **DON'T** reuse data collected for one purpose (e.g., security verification) for another (e.g., marketing) without explicit user consent.
#### Code Examples:
**Fuzzing Data Collection (HTML/JS)**
Instead of asking for exact age:
```html
<label for="age-bracket">Age Bracket:</label>
<select id="age-bracket" name="age-bracket">
<option value="18-34">18-34</option>
<option value="35-49">35-49</option>
<option value="50+">50+</option>
</select>
```
### 2. Transparency and Trust
Build trust by being open about your data practices and providing easy ways for users to control their data.
#### DOs:
* **DO** provide inline explanations for why data is requested. Place the explanation directly next to the input field.
* **DO** provide a clear reason and context *before* requesting powerful browser permissions (e.g., camera, location).
* **DO** consider using the **Page Embedded Permission Control (PEPC)** `<permission>` element, if supported, to make permission requests declarative, user-initiated, and act as data mediators.
* **DO** use the `Clear-Site-Data` header when a user logs out to ensure no lingering data remains in the browser.
* **DO** make it as easy to opt-out or delete an account as it was to sign up.
#### DON'Ts:
* **DON'T** bury data collection explanations in long, complex privacy policies.
* **DON'T** use deceptive patterns (dark patterns) to trick users into giving consent.
#### Code Examples:
**Inline Transparency (HTML)**
```html
<div>
<label for="phone">Phone Number (Optional)</label>
<input id="phone" type="tel" name="phone">
<a href="#phone-help">Why do we ask for this?</a>
<aside id="phone-help">
We only use your phone number to send two-factor authentication codes for account security.
</aside>
</div>
```
**Clear-Site-Data on Logout (HTTP Response)**
```http
HTTP/1.1 200 OK
Clear-Site-Data: "*"
```
*Note: If clearing the cache, avoid sending this on the main navigation page to prevent blocking UI rendering on slow devices; trigger it via a subresource.*
**Page Embedded Permission Control (HTML)**
```html
<!-- Declarative permission element with fallback -->
<permission type="geolocation" onpromptdismiss="updateMap()">
<!-- Fallback for unsupported browsers -->
<button onclick="navigator.geolocation.getCurrentPosition(updateMap)">
Use my location
</button>
</permission>
```
### 3. Security and Data Handling for Privacy
Privacy relies on a foundation of secure coding. Vulnerabilities in the application or insecure storage directly lead to privacy violations.
#### DOs:
* **DO** scrub Personally Identifiable Information (PII) from application logs. Use automated masking for emails, tokens, and IDs.
* **DO** use `HttpOnly` flags for cookies storing session identifiers to prevent other scripts from accessing them.
* **DO** implement rate limiting on sensitive endpoints (e.g., user search or profile views) to prevent bulk data scraping.
* **DO** use **CHIPS (Cookies Having Independent Partitioned State)** by appending the `Partitioned` attribute for 1:1 embeds that do not share state across top-level sites.
#### DON'Ts:
* **DON'T** store sensitive tokens or PII in `localStorage`, as it is accessible by any embedded script.
* **DON'T** rely on unpartitioned `SameSite=None` cookies.
#### Code Examples:
**Secure Session Cookie (HTTP)**
```http
Set-Cookie: session_id=xyz123; Secure; HttpOnly; SameSite=Lax
```
**CHIPS Cookie (HTTP)**
```http
Set-Cookie: theme_pref=dark; SameSite=None; Secure; Path=/; Partitioned; HttpOnly
```
### 4. Third-Party Audits and Mitigations
Third-party scripts and resources are a common source of privacy leaks. You are responsible for the third parties you bring into your application.
#### DOs:
* **DO** conduct regular technical audits of network requests using DevTools or HAR files to identify what data third parties are collecting.
* **DO** use the **Façade Pattern** for heavy embeds (like YouTube or TikTok). Display a static thumbnail and load the interactive iframe only after the user clicks.
* **DO** use privacy-preserving options for embeds when available (e.g., `youtube-nocookie.com`).
* **DO** replace heavy social sharing SDKs with simple, static HTML links that do not track users.
* **DO** use the **Federated Credential Management API (FedCM)** to mediate "Sign-In" flows natively, preventing IdP tracking of Relying Parties prior to user consent.
#### DON'Ts:
* **DON'T** assume a third party is privacy-safe just because it is popular.
* **DON'T** load third-party scripts on pages where sensitive data (like checkout or health info) is handled unless strictly necessary.
#### Code Examples:
**Privacy-Preserving Social Sharing (HTML)**
```html
<!-- No JS SDK required -->
<a href="https://x.com/intent/tweet?text=Check%20this%20out&url=https%3A%2F%2Fexample.com"
rel="noopener" target="_blank">
Share on X
</a>
```
**Video Façade Pattern (HTML/JS)**
```html
<div id="video-container" data-video-id="abc123">
<img src="https://img.youtube.com/vi/abc123/maxresdefault.jpg" alt="Play Video" id="play-btn">
</div>
<script>
document.getElementById('play-btn').addEventListener('click', function() {
const container = document.getElementById('video-container');
const videoId = container.dataset.videoId;
container.innerHTML = `<iframe src="https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1" allowfullscreen></iframe>`;
});
</script>
```
**FedCM Sign-In (JavaScript)**
```javascript
try {
const credential = await navigator.credentials.get({
identity: {
providers: [{
configURL: "https://idp.example/fedcm.json",
clientId: "rp-client-id-123",
nonce: "a_secure_random_nonce_value"
}]
}
});
authenticateWithBackend(credential.token);
} catch (error) {
// Handle FedCM login failure
}
```
### 5. Privacy-Preserving Headers
Use standard HTTP headers to instruct the browser to enforce privacy boundaries.
#### DOs:
* **DO** use `Permissions-Policy` to disable powerful features (like camera, microphone, geolocation) by default, enabling them only where required.
* **DO** set a strict `Referrer-Policy` to prevent leaking sensitive URL parameters to third parties.
#### Code Examples:
**Strict Referrer Policy (HTTP)**
```http
Referrer-Policy: strict-origin-when-cross-origin
```
**Defensive Permissions Policy (HTTP)**
Disables powerful features for all origins by default.
```http
Permissions-Policy: geolocation=(), camera=(), microphone=(), accelerometer=()
```
### 6. Fingerprinting and User-Agent Reduction
Avoid techniques that attempt to uniquely identify users covertly based on their device configuration. Fingerprinting takes away user control because it relies on unchanging characteristics and happens invisibly, preventing users from opting out or clearing their identifier.
#### DOs:
* **DO** use **Feature Detection** instead of User-Agent sniffing to determine if a browser supports a capability.
* **DO** use **User-Agent Client Hints** (UA-CH) if supported by the browser, when specific device targeting is required.
#### DON'Ts:
* **DON'T** use canvas rendering, font lists, or audio/video device enumerations to build a device fingerprint.
* **DON'T** rely on the full granularity of the traditional `navigator.userAgent` string.
#### Code Examples:
**Feature Detection (JavaScript)**
```javascript
// GOOD: Check if the API exists
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver(...);
} else {
// Fallback
}
// BAD: Sniffing UA
// if (navigator.userAgent.includes("Chrome/100")) ...
```
**User-Agent Client Hints (JavaScript)**
```javascript
if (navigator.userAgentData) {
navigator.userAgentData.getHighEntropyValues(["platformVersion", "architecture"])
.then(ua => {
console.log(ua.platformVersion);
});
}
```
### 7. Data Rights and User Control
Empower users to exercise their rights over their personal data.
#### DOs:
* **DO** provide clear mechanisms for users to **access** all data you have collected about them.
* **DO** implement automated or easy manual flows for **data deletion** (erasure).
* **DO** allow users to correct inaccurate information associated with their identity.
#### DON'Ts:
* **DON'T** make the deletion process difficult or require users to contact support if sign-up was automated.
* **DON'T** retaliate against users who exercise their data rights by denying access to non-dependent services.
guides/security/passkey-authentication.md
# Passkey Authentication
This guide details how to implement returning user authentication using discoverable credentials, both through explicit button triggers and seamless browser autofill suggestions (Conditional UI).
## Server-Side
### Options Generation
Create an endpoint that generates WebAuthn request parameters using a vetted library per standards.
1. **Use the predefined RP ID**: Use the predefined proper RP ID as a constant string.
2. **Generate challenge**: Generate a high-entropy, cryptographically secure random buffer, store it securely in the user's session, and encode it as Base64URL.
3. **Discoverable Credentials mapping**: Specify an empty array `[]` for `allowCredentials`. This requests discoverable credentials, meaning the user does not need to enter their username first; the passkey provider will present available accounts.
4. **User Verification level**: Set `userVerification: "preferred"` (or `"required"` if explicitly mandated by corporate compliance policies).
- The requested `userVerification` constraint level MUST be persisted inside the server session record at the options endpoint, rather than passed back from the client via query strings. This allows the verification endpoint to enforce strict matching constraints safely without risk of client manipulation.
```javascript
// Options generation example (discoverable flow)
const options = {
challenge: serverGeneratedBase64UrlChallenge, // High-entropy random challenge stored in session
rpId: "example.com",
allowCredentials: [], // Request discoverable passkeys
userVerification: "preferred",
};
// Persist expected UV level to user session
req.session.expectedUserVerification = "preferred";
```
### Verification Endpoint
Securely verify the assertion returned by the client to authenticate the user:
1. **Validate session challenge**: Enforce strict challenge matching between the client response and the expected challenge stored in the session.
2. **Enforce UV Preferences**:
- Allow UV-less authenticators (e.g., authenticator screen locks disabled) if the session's `expectedUserVerification` requested `"preferred"`, by passing `requireUserVerification: false` to your server-side verification library. If requested `"required"`, enforce biometrics/PIN entry strictly.
3. **Clean Server Error 404**: If the credential ID returned by the client is not found in the database, return an explicit HTTP `404` error so the client can trigger the Signal API.
## Client-Side Logic
### HTML Form Annotation
Annotate your username and password inputs to natively leverage Conditional UI. Autocomplete tokens combine the webauthn spec parameters, and autofocus triggers the browser autofill popup immediately when the input is focused.
```html
<!-- Autocomplete tokens must contain webauthn space-separated -->
<form id="signin-form">
<input
type="text"
name="username"
autocomplete="username webauthn"
autofocus
data-testid="username-field"
/>
<input type="password" name="password" autocomplete="current-password" />
<button type="submit">Sign in</button>
</form>
```
### Explicit Button Flow
Trigger passkey authentication when a user clicks a "Sign in with passkey" button. Abort any ongoing form autofill (Conditional Get) calls before invoking the passkey prompt.
### Conditional Mediation Flow (Form Autofill)
Activate form autofill suggestions on page load to offer passkey authentication natively when users focus on sign-in fields:
1. **Feature detect**: Call `PublicKeyCredential.getClientCapabilities()` on page load and **skip signing in with passkey** if `conditionalGet` is not available.
2. **Decode options**: Decode fetched credential JSON object with `PublicKeyCredential.parseRequestOptionsFromJSON()`.
3. **Invoke Conditional Get**: Call `navigator.credentials.get()` with `mediation: "conditional"` and pass an `AbortController` signal. This registers autofill silently without rendering a passkey dialog.
4. **Try/Catch Exception Segregation**: Wrap `navigator.credentials.get` call in try/catch block:
- `NotAllowedError`: The user cancelled or timed out the passkey login prompt.
- `AbortError`: The authentication request was cancelled programmatically.
5. **Call Signal API**: Wrap server verification `fetch()` call in a try/catch block:
- Show an error message for the user to understand what went wrong.
- Call `signalUnknownCredential()` ONLY when the server explicitly responds with HTTP status `404` (Credential not found) and the user is unauthenticated.
- The `credentialId` parameter passed to `signalUnknownCredential()` MUST strictly be the Base64URL-encoded credential ID string (e.g., `encoded.id`), NOT the raw ArrayBuffer object `credential.rawId`.
6. **Encode the response**: Encode the credential `AuthenticatorAssertionResponse` with `.toJSON()` before sending it to the server for verification.
```javascript
// optionsFetch and loginVerifyFetch are app-defined HTTP methods
import { optionsFetch, loginVerifyFetch } from "./api.js";
let autofillAbortController = new AbortController();
async function initializeConditionalAutofill() {
// Feature detect Conditional Get autofill support
const capabilities = await PublicKeyCredential.getClientCapabilities();
if (capabilities.conditionalGet === true) {
const loginOptionsJSON = await optionsFetch();
const publicKey =
PublicKeyCredential.parseRequestOptionsFromJSON(loginOptionsJSON);
try {
// Initiate Conditional UI form autofill suggestions
const credential = await navigator.credentials.get({
publicKey,
signal: autofillAbortController.signal,
mediation: "conditional",
});
// Segregated verification fetch
const encoded = credential.toJSON();
const response = await loginVerifyFetch(encoded);
if (!response.ok && response.status === 404) {
// Note: this code path runs pre-authentication, satisfying the unauth precondition
if (PublicKeyCredential.signalUnknownCredential) {
await PublicKeyCredential.signalUnknownCredential({
rpId, // RP ID must match the one defined on the server
credentialId: encoded.id,
});
}
}
} catch (err) {
// Silently swallow expected client WebAuthn exceptions
if (["NotAllowedError", "AbortError"].includes(err.name)) {
return;
}
console.error("Unexpected conditional get error:", err);
}
}
}
async function triggerButtonAuthentication() {
// Abort any pending Conditional Get call to prevent passkey prompt collisions
autofillAbortController.abort();
autofillAbortController = new AbortController(); // Reset controller for next triggers
const loginOptionsJSON = await optionsFetch();
const publicKey =
PublicKeyCredential.parseRequestOptionsFromJSON(loginOptionsJSON);
let credential;
try {
// Passkey explicit prompt trigger
credential = await navigator.credentials.get({
publicKey,
signal: autofillAbortController.signal,
});
} catch (err) {
if (err.name === "NotAllowedError") {
console.log("User cancelled passkey login.");
} else if (err.name === "AbortError") {
console.log("The authentication operation was aborted.");
}
// Re-arm Conditional autofill Suggestions after cancelled explicit button prompts
initializeConditionalAutofill();
return; // Safe exit
}
// Segregated verification try/catch (HTTP 404 trigger)
const encoded = credential.toJSON();
try {
const response = await loginVerifyFetch(encoded);
if (!response.ok && response.status === 404) {
// Note: this code path runs pre-authentication, satisfying the unauth precondition
await PublicKeyCredential.signalUnknownCredential({
rpId, // RP ID must match the one defined on the server
credentialId: encoded.id, // Base64URL-encoded credential ID
});
}
} catch (serverErr) {
console.error("Verification request error:", serverErr);
}
}
// Trigger Conditional Get on load
window.addEventListener("DOMContentLoaded", initializeConditionalAutofill);
```
## Fallback Strategies
### Passkey feature detection fallback
Baseline status for the api.PublicKeyCredential.getClientCapabilities_static capability: Newly available. It's been Baseline since 2025-02-06.
Supported by: Chrome 133 (Feb 2025), Edge 133 (Feb 2025), Firefox 135 (Feb 2025), and Safari 17.4 (Mar 2024).
Always install 'webauthn-polyfills' and import it in the context.
Consider as long as `PublicKeyCredential` is supported, `PublicKeyCredential.getClientCapabilities` is also supported.
```js
import 'webauthn-polyfills';
```
### Signal API Synchronization Fallback
Web authentication signal methods has limited availability.
Supported by: Chrome 132 (Jan 2025), Edge 132 (Jan 2025), and Safari 26 (Sep 2025).
Unsupported in: Firefox.
The WebAuthn Signal API (`webauthn-signals`) is a progressive optimization used to keep password managers in sync with the server credential state.
- **Fallback Experience**: Gated via `if (PublicKeyCredential.signalUnknownCredential)`. If unsupported, the background verification sync is bypassed gracefully without throwing browser exceptions.
### Easy JSON Serialization Fallback
Baseline status for the api.PublicKeyCredential.parseRequestOptionsFromJSON_static capability: Newly available. It's been Baseline since 2025-03-31.
Supported by: Chrome 129 (Sep 2024), Edge 129 (Sep 2024), Firefox 119 (Oct 2023), and Safari 18.4 (Mar 2025).
Always install 'webauthn-polyfills' and import it in the context.
Consider as long as `PublicKeyCredential` is supported, `PublicKeyCredential.parseRequestOptionsFromJSON` and `PublicKeyCredential.prototype.toJSON` are also supported.
```js
import 'webauthn-polyfills';
```
guides/security/passkey-conditional-create.md
# Passkey Conditional Create (Post-Login Promotion)
This guide details how to automatically and silently register a passkey for a user immediately after a successful password-based sign-in, minimizing friction and boosting passkey adoption.
## The Right Trigger Moment
Automatic passkey creation (also known as Conditional Create or silent post-login promotion) MUST only be triggered **immediately after a successful, full sign-in that involved a password**.
* Do not attempt conditional creation for passwordless flows (e.g., magic links, SMS OTP, or identity federation).
* If multi-factor authentication is required, you MUST wait until all factors have succeeded before initiating conditional creation.
* Ensure a valid, authenticated user session is active before making requests to creation endpoints.
## Implementation Steps
### 1. Abort Prior Autofill Actions
If the sign-in page utilizes form autofill (Conditional UI/Get), the active credential get call must be aborted to prevent browser conflicts.
* Call `abortController.abort()` on the `AbortController` attached to the pending `navigator.credentials.get()` autofill request before calling `navigator.credentials.create()`.
### 2. Feature Detection
Determine whether Conditional Create is available by checking `conditionalCreate` with `PublicKeyCredential.getClientCapabilities()`.
```javascript
const capabilities = await PublicKeyCredential.getClientCapabilities();
if (capabilities.conditionalCreate) {
// Conditional create is available
}
```
### 3. Create a passkey with Conditional Create
* Pass `mediation: 'conditional'` within the `navigator.credentials.create()` options. This signals the browser to handle the passkey creation flow silently in the background or contextually without throwing obtrusive modal dialogs.
* Populate `excludeCredentials` with the user's existing passkey credential IDs to avoid registering duplicate keys.
### 4. Silent Error Handling
* Wrap the passkey creation prompt (`navigator.credentials.create`) in a try/catch block. You MUST catch and silently ignore typical user-facing exceptions (`InvalidStateError`, `NotAllowedError`, `AbortError`) without rendering any error UI to the user.
### 5. Server-Side User Presence Verification
* The server-side verification endpoint MUST relax the User Presence (UP) requirement (`requireUserPresence: false`) **ONLY** when verifying credentials produced by a conditional-create trigger. Strict presence verification must remain active for standard explicit creations.
### 6. Handle Failed Server Verification gracefully
* If `navigator.credentials.create()` succeeds but the server verification fetch returns a bad response (e.g., signature verification fails), invoke `PublicKeyCredential.signalUnknownCredential()` to prevent orphaned credentials from lingering in the passkey provider.
## Code Example
```javascript
// optionsFetch and registerVerifyFetch are app-defined server endpoint requests
import { optionsFetch, registerVerifyFetch } from './api.js';
async function triggerConditionalCreate(loginAbortController) {
const capabilities = await PublicKeyCredential.getClientCapabilities();
if (capabilities.conditionalCreate !== true) {
return; // Platform does not support conditional creation
}
// 1. Abort any active autofill conditional-get controllers to clear the WebAuthn pipeline
loginAbortController.abort();
// 2. Fetch creation options signaling the backend that this is a conditional request
const creationOptionsJSON = await optionsFetch({ conditional: true });
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(creationOptionsJSON);
let credential;
try {
// 3. Invoke silent credentials creation prompt
credential = await navigator.credentials.create({
publicKey,
mediation: 'conditional' // Silent background creation mediation
});
} catch (e) {
// 4. Silently swallow common WebAuthn browser exceptions
if (['InvalidStateError', 'NotAllowedError', 'AbortError'].includes(e.name)) {
return;
}
console.error('Unexpected conditional create error:', e);
return;
}
// 5. Server verification step using dedicated Try/Catch block
let encodedResponse = credential.toJSON();
try {
const response = await registerVerifyFetch(encodedResponse);
if (!response.ok) {
// If the server verification fails, clean up using Signal API
if (PublicKeyCredential.signalUnknownCredential) {
await PublicKeyCredential.signalUnknownCredential({
rpId, // RP ID must match the one defined on the server
credentialId: encodedResponse.id
});
}
}
} catch (serverErr) {
console.error('Verification network failure:', serverErr);
if (PublicKeyCredential.signalUnknownCredential) {
await PublicKeyCredential.signalUnknownCredential({
rpId, // RP ID must match the one defined on the server
credentialId: encodedResponse.id
});
}
}
}
```
## Fallback Strategies
### Passkey feature detection fallback
Baseline status for the api.PublicKeyCredential.getClientCapabilities_static capability: Newly available. It's been Baseline since 2025-02-06.
Supported by: Chrome 133 (Feb 2025), Edge 133 (Feb 2025), Firefox 135 (Feb 2025), and Safari 17.4 (Mar 2024).
Always install 'webauthn-polyfills' and import it in the context.
Consider as long as `PublicKeyCredential` is supported, `PublicKeyCredential.getClientCapabilities` is also supported.
```js
import 'webauthn-polyfills';
```
### Signal API Synchronization Fallback
Web authentication signal methods has limited availability.
Supported by: Chrome 132 (Jan 2025), Edge 132 (Jan 2025), and Safari 26 (Sep 2025).
Unsupported in: Firefox.
The WebAuthn Signal API (`webauthn-signals`) is a progressive optimization used to keep password managers in sync with the server credential state.
* **Fallback Experience**: Gated via `if (PublicKeyCredential.signalUnknownCredential)`. If unsupported, the background verification sync is bypassed gracefully without throwing browser exceptions.
### Easy JSON Serialization Fallback
Baseline status for the api.PublicKeyCredential.parseCreationOptionsFromJSON_static capability: Newly available. It's been Baseline since 2025-03-31.
Supported by: Chrome 129 (Sep 2024), Edge 129 (Sep 2024), Firefox 119 (Oct 2023), and Safari 18.4 (Mar 2025).
Always install 'webauthn-polyfills' and import it in the context.
Consider as long as `PublicKeyCredential` is supported, `PublicKeyCredential.parseCreationOptionsFromJSON` and `PublicKeyCredential.prototype.toJSON` are also supported.
```js
import 'webauthn-polyfills';
```
guides/security/passkey-management.md
# Passkey Management
This guide details how to enable users to view, rename, and delete their registered passkeys while keeping saved credentials perfectly synchronized between the server and the user's password managers using the Signal API.
## Server-Side Operations
Your backend database layer and endpoints MUST support common CRUD actions for registered credentials. Decoupled from framework-specific libraries, the server exposes endpoints to:
1. **List all user credentials**: Fetch all `StoredPasskeyCredential` records matching the signed-in user's ID.
2. **Update credential names**: Accept a new custom string name for a specific credential ID and persist the update.
3. **Delete credentials**: Remove a specific credential ID from the database.
```javascript
// Node.js routing example for credential CRUD
router.get('/api/credentials', checkUserAuthenticated, async (req, res) => {
const list = await db.findCredentialsByUserId(req.user.id);
return res.json(list);
});
router.put('/api/credential/:id', checkUserAuthenticated, async (req, res) => {
const { id } = req.params;
const { name } = req.body;
const cred = await db.findCredentialById(id);
if (!cred || cred.passkeyUserId !== req.user.id) {
return res.status(404).json({ error: 'Credential not found.' });
}
cred.name = name;
await db.saveCredential(cred);
return res.json(cred);
});
router.delete('/api/credential/:id', checkUserAuthenticated, async (req, res) => {
const { id } = req.params;
const cred = await db.findCredentialById(id);
if (!cred || cred.passkeyUserId !== req.user.id) {
return res.status(404).json({ error: 'Credential not found.' });
}
await db.deleteCredential(id);
return res.json({ success: true });
});
```
## Client-Side Management UI
Render a dedicated settings panel allowing users to easily audit and manage their registered authentication options:
1. **Display saved list**: Fetch list from your endpoint and render individual credential rows. If the response is empty, render a helpful empty-state message (e.g., "No passkeys found").
2. **Map AAGUID Metadata**: For each passkey, lookup its `aaguid` property against your local registry to render its provider details. See [Determine the passkey provider from AAGUID](#aaguid) section for more details.
3. **Per-Item UI Requirements**: Every row inside the list container MUST render:
* **Provider Icon**: AAGUID-derived image or data URI.
* **Provider/Custom Name**: AAGUID-derived name or user-renamed string.
* **Registration Date**: The database-persisted raw epoch timestamp `registeredAt` formatted to a human-readable date for client display.
* **Last Used Date**: The database-persisted raw epoch timestamp `lastUsedAt` formatted to a human-readable date (if present) for client display.
* **Rename Button**: Triggers a rename text input modal.
* **Delete Button**: Triggers deletion.
4. **Conditional "Create Passkey" Button**:
* Offer a prominent "Create passkey" registration trigger button on the management page. Before rendering this UI element, the page MUST feature-detect capabilities using `PublicKeyCredential.getClientCapabilities()` to verify platform authenticator is supported. If passkeys are unsupported, hide this button and gracefully encourage standard MFA enrollments instead.
* Allow registering a security key by omitting `authenticatorSelection.authenticatorAttachment` on `navigator.credentials.create()` call.
## Signal API Synchronization
The Signal API lets the application communicate credential states to password managers, keeping the user's synced vaults and your backend database in lockstep.
* **Parameter Encoding Rule**:
* All `userId` and credential ID parameters passed to Signal API methods (`signalAllAcceptedCredentials`, `signalCurrentUserDetails`) MUST be **Base64URL-encoded strings**.
* **Initiating Page Load Sync**:
* The application MUST invoke `signalAllAcceptedCredentials()` automatically in a `DOMContentLoaded` page load event listener.
* **Management Updates Sync**:
* The application MUST invoke `signalAllAcceptedCredentials()` immediately within your delete credential click handler post-fetch.
* The application MUST invoke `signalCurrentUserDetails()` immediately within your username or display name rename click handler post-fetch.
```javascript
// Client-side management synchronization ES module
import { listFetch, renameFetch, deleteFetch } from './api.js';
// Base64URL-encoded User ID string (illustration only)
const base64UrlUserId = "M2YPl-KGnA8";
async function syncAcceptedCredentials(currentCredentialsList) {
try {
const credentialIds = currentCredentialsList.map(c => c.id); // Map of Base64URL credential ID strings
await PublicKeyCredential.signalAllAcceptedCredentials({
rpId, // RP ID must match the one defined on the server
userId: base64UrlUserId, // User ID Base64URL-encoded string
allAcceptedCredentialIds: credentialIds
});
} catch (e) {
console.error('SignalAllAcceptedCredentials sync failure:', e);
}
}
async function loadManagementPanel() {
const response = await listFetch();
const list = await response.json();
renderUI(list);
// Sync on page load
await syncAcceptedCredentials(list);
}
async function performDelete(credentialId) {
const response = await deleteFetch(credentialId);
if (response.ok) {
const updatedResponse = await listFetch();
const updatedList = await updatedResponse.json();
renderUI(updatedList);
// Sync after deletion
await syncAcceptedCredentials(updatedList);
}
}
async function performRename(rpId, userId, updatedName, updatedDisplayName) {
const response = await renameFetch({ name: updatedName, displayName: updatedDisplayName });
if (response.ok) {
try {
await PublicKeyCredential.signalCurrentUserDetails({
rpId, // RP ID must match the one defined on the server
userId, // Base64URL-encoded user ID
name: updatedName, // Updated username
displayName: updatedDisplayName // Updated display name
});
} catch (e) {
console.error('SignalCurrentUserDetails sync failure:', e);
}
}
}
```
## Determine the passkey provider from AAGUID {: #aaguid }
An AAGUID (Authenticator Attestation Globally Unique Identifier) is a 128-bit identifier that represents the model of the authenticator, not a specific instance. It is included in the authenticator data during passkey registration and can be used to determine which passkey provider (e.g. Google Password Manager, iCloud Keychain, 1Password) created a credential.
AAGUID should only be used to help users with passkey management. It can be modified unless cryptographically attested, which platform passkeys currently don't support.
### 1. AAGUID Registry
A community-maintained JSON mapping of AAGUIDs to provider names and icons is available at:
```
https://raw.githubusercontent.com/passkeydeveloper/passkey-authenticator-aaguids/refs/heads/main/combined_aaguid.json
```
Each entry has the following schema:
```json
{
"<aaguid-uuid>": {
"name": "Provider Name",
"icon_light": "data:image/png;base64,...",
"icon_dark": "data:image/png;base64,..."
}
}
```
### 2. Using AAGUID After Registration
After verifying a registration response, read the `aaguid` from the registration result and look it up against the registry to populate the credential's `name` and `providerIcon`:
Before looking up the AAGUID in the registry, check if it equals `'00000000-0000-0000-0000-000000000000'`. If so, skip the registry lookup and set `name` to a fallback (e.g. device name from user-agent, or "Unknown passkey provider") and `providerIcon` to `undefined`. Only look up the registry for non-zeroed AAGUIDs.
```javascript
import aaguids from './aaguids.json' with { type: 'json' };
const { aaguid } = registrationInfo;
if (aaguid === '00000000-0000-0000-0000-000000000000') {
// use the device name as the passkey provider based on
// the information derived from the user agent string,
// or just say "Unknown passkey provider"
} else {
const provider = aaguids[aaguid];
const credential = {
// ...other fields
aaguid,
name: provider?.name || 'Unknown passkey provider',
providerIcon: provider?.icon_light,
};
}
```
## Fallback Strategies
### Passkey feature detection fallback
Baseline status for the api.PublicKeyCredential.getClientCapabilities_static capability: Newly available. It's been Baseline since 2025-02-06.
Supported by: Chrome 133 (Feb 2025), Edge 133 (Feb 2025), Firefox 135 (Feb 2025), and Safari 17.4 (Mar 2024).
Always install 'webauthn-polyfills' and import it in the context.
Consider as long as `PublicKeyCredential` is supported, `PublicKeyCredential.getClientCapabilities` is also supported.
```js
import 'webauthn-polyfills';
```
### Signal API Synchronization Fallback
Web authentication signal methods has limited availability.
Supported by: Chrome 132 (Jan 2025), Edge 132 (Jan 2025), and Safari 26 (Sep 2025).
Unsupported in: Firefox.
If the browser does not support `PublicKeyCredential.parseRequestOptionsFromJSON`, use the 'webauthn-polyfills':
```html
<script type="module">
if (!PublicKeyCredential.parseRequestOptionsFromJSON) {
await import('https://unpkg.com/webauthn-polyfills');
}
</script>
```
This will also add support for `PublicKeyCredential.prototype.toJSON`.
guides/security/passkey-reauthentication.md
# Passkey Reauthentication
This delta-focused guide details how to implement step-up authentication or re-verification for a signed-in user before they perform sensitive account changes (e.g. passwords updates, financial transfers).
## Delta Flow Architecture
Unlike regular authentication, passkey reauthentication constrains passkey dialog prompts strictly to the logged-in user's pre-registered credentials to prevent account-mixing or passkey spoofing during active sessions.
## Server-Side
### Options Generation Delta
Create an endpoint that populates the allowed credentials parameters specifically for the active, known user:
**Constrain Credentials**: Populate the `allowCredentials` options array with specific `PublicKeyCredentialDescriptor` records mapping all registered credential IDs for the signed-in user. Leaving this empty or omitting it regresses to discoverable credentials, violating session safety.
```javascript
// Node.js step-up options generation example
router.post("/api/reauth/options", enforceActiveSession, async (req, res) => {
const userPasskeys = await db.findCredentialsByUserId(req.user.id);
const options = {
challenge: serverGeneratedBase64UrlChallenge, // Random challenge stored in user session
rpId: "example.com",
// Enforce allowance strictly limited to the user's credentials list
allowCredentials: userPasskeys.map((cred) => ({
type: "public-key",
id: cred.id,
transports: cred.transports, // Speeds up resolution by indicating platform transports
})),
};
return res.json(options);
});
```
### Verification Endpoint Delta
Verify the assertion returned by the client:
**Verify Account Ownership**: The verification endpoint MUST explicitly verify that the resulting authenticated credential ID returned by the client resolves to a stored credential record whose associated user ID strictly matches the active signed-in user (`storedCredential.passkeyUserId === req.user.id`). If a valid passkey of a _different_ user is returned, authentication MUST be rejected immediately.
## Client-Side Flow Deltas
Applications choose from two reauthentication interfaces depending on the transaction UI:
### A. Button Flow (No Input Fields)
Trigger reauthentication when a user presses a "Verify Identity" or "Proceed with Transaction" button.
```html
<button id="reauth-btn" data-testid="reauth-button">Confirm Transaction</button>
```
```javascript
let reauthAbortController = new AbortController();
async function triggerButtonReauth() {
// Abort any background suggestion flows to avoid passkey prompt collisions
reauthAbortController.abort();
reauthAbortController = new AbortController();
const optionsResponse = await fetch("/api/reauth/options", {
method: "POST",
});
const optionsJSON = await optionsResponse.json();
const publicKey =
PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);
try {
const credential = await navigator.credentials.get({
publicKey,
signal: reauthAbortController.signal,
});
if (credential) {
const encodedCredential = credential.toJSON();
const verifyResponse = await fetch("/api/reauth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(encodedCredential),
});
if (verifyResponse.ok) {
showTransactionSuccessUI();
} else if (verifyResponse.status === 404 && PublicKeyCredential.signalUnknownCredential) {
await PublicKeyCredential.signalUnknownCredential({
rpId, // RP ID must match the one defined on the server
credentialId: encodedCredential.id
});
}
}
} catch (err) {
if (err.name === "NotAllowedError") {
console.log("User cancelled reauthentication.");
}
}
}
document
.getElementById("reauth-btn")
.addEventListener("click", triggerButtonReauth);
```
## Fallback Strategies
### Passkey feature detection fallback
Baseline status for the api.PublicKeyCredential.getClientCapabilities_static capability: Newly available. It's been Baseline since 2025-02-06.
Supported by: Chrome 133 (Feb 2025), Edge 133 (Feb 2025), Firefox 135 (Feb 2025), and Safari 17.4 (Mar 2024).
Always install 'webauthn-polyfills' and import it in the context.
Consider as long as `PublicKeyCredential` is supported, `PublicKeyCredential.getClientCapabilities` is also supported.
```js
import 'webauthn-polyfills';
```
### Easy JSON Serialization Fallback
Baseline status for the api.PublicKeyCredential.parseRequestOptionsFromJSON_static capability: Newly available. It's been Baseline since 2025-03-31.
Supported by: Chrome 129 (Sep 2024), Edge 129 (Sep 2024), Firefox 119 (Oct 2023), and Safari 18.4 (Mar 2025).
Always install 'webauthn-polyfills' and import it in the context.
Consider as long as `PublicKeyCredential` is supported, `PublicKeyCredential.parseRequestOptionsFromJSON` and `PublicKeyCredential.prototype.toJSON` are also supported.
```js
import 'webauthn-polyfills';
```
guides/security/passkey-registration.md
# Passkey Registration
This guide details how to enable users to register a passkey for their account, providing a highly secure, phishing-resistant passwordless sign-in alternative.
## Database Requirements
To support passkey registrations, your database credential table must store the following fields:
```typescript
export interface StoredPasskeyCredential {
id: string; // Base64URL-encoded credential ID (unique lookup key)
passkeyUserId: string; // Associated application user ID
credentialPublicKey: string; // Base64URL-encoded public key used to verify assertion signatures
credentialType: "public-key";
credentialDeviceType: "singleDevice" | "multiDevice"; // Helps distinguish device-bound vs cloud-synced passkeys
credentialBackedUp: boolean; // Boolean backup state reported by the authenticator
aaguid: string; // Authenticator Attestation GUID
providerIcon?: string; // Provider icon derived from the AAGUID registry (dark or light theme URLs)
name: string; // Provider name derived from AAGUID registry
transports: string[]; // Array of transport names (e.g. 'internal', 'hybrid') necessary for exclusion options
lastUsedAt?: number; // Optional epoch timestamp of last sign-in
registeredAt: number; // Registration epoch timestamp
counter: number; // Authenticator sign-in signature counter used to prevent replay attacks
}
```
## Server-Side
### Options Generation
Create an endpoint that generates WebAuthn creation parameters. Rely on a vetted library per category standards instead of hand-rolling cryptography.
1. **Use the predefined RP ID**: Use the predefined proper RP ID as a constant string.
2. **Create a secure Challenge**: Generate a high-entropy, cryptographically secure random buffer on the server, store it securely in the user's session, and encode it as Base64URL for options delivery.
3. **Avoid Duplicate Passkeys**: Map the user's existing pre-registered credential IDs to the `excludeCredentials` options array. This prevents the authenticator from registering duplicate credentials on the same passkey provider account.
4. **Enforce Discoverable Credentials**: Set `requireResidentKey: true` and `residentKey: "required"` in the `authenticatorSelection` options to request a discoverable credential, which is necessary for discoverable sign-ins.
5. **Configure User Verification**: Specify `userVerification: "preferred"` or `userVerification: "required"`. Many compliance use cases (e.g., finance, healthcare) require `'required'` to enforce user verification on creation.
6. **Determine Attachment Scope**:
- **Promotion Flow**: When proposing passkey creation right after standard password sign-ins or post-signup promotions, set `authenticatorAttachment: "platform"` to enforce platform authenticator and bypass external security key prompts.
- **Management Flow**: When called from a dedicated settings or security panel where external security keys are supported in addition to platform authenticator, omit the `authenticatorAttachment` property entirely.
- _Tip_: Accept a `promotion: boolean` request flag to conditionally handle both flows with a single endpoint.
```javascript
// Options generation example
const options = {
challenge: serverGeneratedBase64UrlChallenge, // Cryptographically random challenge
rp: { id: "example.com", name: "Secure Application" },
user: {
id: userBase64UrlId, // Unique base64url string identifying the account
name: "user@example.com",
displayName: "Jane Doe",
},
pubKeyCredParams: [
{
type: "public-key",
alg: -7,
},
{
type: "public-key",
alg: -257,
},
],
excludeCredentials: userExistingCredentials.map((cred) => ({
type: "public-key",
id: cred.id,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: "required",
requireResidentKey: true,
userVerification: "preferred",
...(isPromotionFlow && { authenticatorAttachment: "platform" }),
},
};
```
### Verification
1. **Challenge Verification**: Securely verify the challenge against the expected session bound challenge.
2. **Verify User Presence**:
- Ensure that the User Present (UP) flag returned in the parsed authenticator data is `true` to confirm physical user presence at the time of creation.
3. **Relaxing Verification for 'preferred'**:
- When the creation options specified `userVerification: "preferred"`, the server-side verification call MUST be configured with `requireUserVerification: false`. Otherwise, authenticators that register without user verification (e.g., screen locks disabled) will trigger spurious server verification failures.
## Client-Side Logic
1. **Gate the UI on page load**:
- On page load, call `PublicKeyCredential.getClientCapabilities()` and **disable the "Create passkey" button** if `conditionalGet` or `passkeyPlatformAuthenticator` is not available.
2. **Invoke creation & Serialize**: Decode server options with `PublicKeyCredential.parseCreationOptionsFromJSON()` and pass the resulting configuration to `navigator.credentials.create()`.
- Call `credential.toJSON()` to encode the `AuthenticatorAttestationResponse` into a valid, JSON-serializable object before fetching the verification endpoint.
3. **Handle WebAuthn Exceptions**:
- `InvalidStateError`: A matching passkey already exists (matched by `excludeCredentials`).
- `NotAllowedError`: The user cancelled or timed out the authentication passkey dialog.
- `AbortError`: The operation has been aborted.
- `SecurityError`: Secure origins (HTTPS) or RP ID mismatch errors (configuration issues).
4. **Try/Catch Segregation for Signal API**:
- Wrap server verification `fetch()` call in a try/catch block. Call `signalUnknownCredential()` when the server verification fetch fails (any status `response.ok === false` or network throws).
```javascript
// optionsFetch and registerVerifyFetch are app-defined HTTP methods
import { optionsFetch, registerVerifyFetch } from "./api.js";
async function registerPasskey(isPromotion = false) {
// Verify passkey capability and conditional UI are available
const capabilities = await PublicKeyCredential.getClientCapabilities();
if (
!capabilities.passkeyPlatformAuthenticator ||
!capabilities.conditionalGet
) {
// Hide "Create passkey" buttons and fall back to password flows instead
showStandardPasswordFallbackUI();
return;
}
const creationOptionsJSON = await optionsFetch({ promotion: isPromotion });
const publicKey =
PublicKeyCredential.parseCreationOptionsFromJSON(creationOptionsJSON);
let credential;
try {
// passkey prompt execution
credential = await navigator.credentials.create({ publicKey });
} catch (err) {
if (err.name === "InvalidStateError") {
console.log("A passkey already exists for this account.");
alert("A passkey already exists for this account.");
} else if (err.name === "SecurityError") {
console.error("Configuration RP ID or Secure Context error.");
alert("Configuration RP ID or Secure Context error.");
} else if (err.name === "NotAllowedError") {
console.log("User cancelled the passkey dialog.");
} else if (err.name === "AbortError") {
console.log("The creation operation has been aborted.");
}
return; // Safe API exit, do not signal unknown for standard WebAuthn cancels
}
// Server Verification phase (Segregated Try/Catch)
let encodedResponse = credential.toJSON();
try {
const response = await registerVerifyFetch(encodedResponse);
if (!response.ok) {
// Server verification failed to verify/authenticate the credential (orphaned)
await PublicKeyCredential.signalUnknownCredential({
rpId, // RP ID must match the one defined on the server
credentialId: encodedResponse.id, // Base64URL-encoded credential ID
});
}
} catch (serverErr) {
console.error("Server verification network failure:", serverErr);
await publickeycredential.signalunknowncredential({
rpId, // RP ID must match the one defined on the server
credentialid: encodedresponse.id, // base64url-encoded credential id
});
}
}
```
## Fallback Strategies
### Signal API Synchronization Fallback
Web authentication signal methods has limited availability.
Supported by: Chrome 132 (Jan 2025), Edge 132 (Jan 2025), and Safari 26 (Sep 2025).
Unsupported in: Firefox.
The WebAuthn Signal API (`webauthn-signals`) is a progressive optimization used to keep password managers in sync with the server credential state.
- **Fallback Experience**: If `PublicKeyCredential.signalUnknownCredential` is unsupported by the browser, the call MUST be bypassed safely via feature detection gating (`if (PublicKeyCredential.signalUnknownCredential)`), and the server-side verification simply logs the failure without triggering manager updates.
### Easy JSON Serialization Fallback
Baseline status for the api.PublicKeyCredential.parseCreationOptionsFromJSON_static capability: Newly available. It's been Baseline since 2025-03-31.
Supported by: Chrome 129 (Sep 2024), Edge 129 (Sep 2024), Firefox 119 (Oct 2023), and Safari 18.4 (Mar 2025).
Always install 'webauthn-polyfills' and import it in the context.
Consider as long as `PublicKeyCredential` is supported, `PublicKeyCredential.parseCreationOptionsFromJSON` and `PublicKeyCredential.prototype.toJSON` are also supported.
```js
import 'webauthn-polyfills';
```
guides/security/passkeys.md
# Passkeys Orientation
This guide provides high-density, action-oriented orientation for implementing secure, framework-agnostic passkey authentication and credential management in modern web applications.
## 1. Core Prerequisites for Passkeys
Passkeys rely on the Web Authentication API (WebAuthn), which imposes strict cross-cutting security constraints that must be satisfied before any implementation attempt:
* **Secure Contexts**: WebAuthn methods (`navigator.credentials.create` and `navigator.credentials.get`) are strictly gated behind Secure Contexts. Applications MUST run on `https://` in production, or `http://localhost` for local development.
* **Relying Party (RP) ID**: Every credential is tied to an RP ID (essentially the domain name of the application). The RP ID passed in the server-side options MUST match or be a valid suffix of the current origin's domain name (e.g., `example.com` is valid for `login.example.com`). Mismatches result in `SecurityError` exceptions on the client side.
## 2. The AAGUID UX Caveat
The Authenticator Attestation Globally Unique Identifier (AAGUID) is a 128-bit identifier returned in the registration attestation data that represents the model/provider of the authenticator (e.g., Google Password Manager, iCloud Keychain, 1Password).
* **UX Hinting Only**: Relying Parties MUST use the AAGUID exclusively for UX hints (such as rendering the passkey provider name and icon in a management list to help the user).
* **No Security Dependencies**: applications MUST NOT use AAGUID for cryptographic security or access decisions. Platform passkeys do not currently provide cryptographic attestation for their AAGUIDs, meaning it can be altered or simulated by user agents.
## 3. Decoupled Library Recommendations
For backend FIDO2/WebAuthn options generation and signature verification, developers MUST rely on vetted open source libraries per language instead of hand-rolling cryptography:
* **JavaScript/TypeScript**: SimpleWebAuthn (github.com/MasterKale/SimpleWebAuthn)
* **Python**: py_webauthn (github.com/duo-labs/py_webauthn)
* **Java**: Java WebAuthn Server (github.com/Yubico/java-webauthn-server), WebAuthn4J (github.com/webauthn4j/webauthn4j)
* **.NET**: .NET library for FIDO2 (github.com/abergs/fido2-net-lib)
* **Go**: WebAuthn Go Library (github.com/go-webauthn/webauthn)
* **Ruby**: WebAuthn Ruby (github.com/cedarcode/webauthn-ruby)
* **PHP**: WebAuthn Framework (github.com/web-auth/webauthn-framework)
## 4. Use Case Reference Matrix
Identify the matching use case below and retrieve its full implementation guide. Every use case has critical APIs (`PublicKeyCredential.parseCreationOptionsFromJSON`, `parseRequestOptionsFromJSON`, `signalAllAcceptedCredentials`, `signalCurrentUserDetails`, `signalUnknownCredential`, conditional mediation, AAGUID handling, etc.) that are documented only in the per-use-case guide. Do NOT skip this retrieval step, and do NOT substitute third-party library wrappers (such as SimpleWebAuthn's `startAuthentication`/`startRegistration`) on the client — call the native WebAuthn browser APIs directly. Library recommendations in Section 3 apply to the **server-side** (backend FIDO2 verification) only.
Specific passkey and WebAuthn implementation details are mapped to the following guides:
* **Passkey Registration**: `passkey-registration` (via `npx -y modern-web-guidance@latest retrieve "passkey-registration"`) — Offering new passkey registration and promotions.
* **Passkey Conditional Create**: `passkey-conditional-create` (via `npx -y modern-web-guidance@latest retrieve "passkey-conditional-create"`) — Silently registering passkeys immediately after successful password login.
* **Passkey Authentication**: `passkey-authentication` (via `npx -y modern-web-guidance@latest retrieve "passkey-authentication"`) — Discoverable-autofill and button sign-ins.
* **Passkey Management**: `passkey-management` (via `npx -y modern-web-guidance@latest retrieve "passkey-management"`) — Syncing lists, renames, and deletions with password managers.
* **Passkey Reauthentication**: `passkey-reauthentication` (via `npx -y modern-web-guidance@latest retrieve "passkey-reauthentication"`) — Re-verifying returning signed-in users for sensitive steps.
guides/security/security.md
# Web Security
Guidelines for implementing preventative security measures on the web safely and incrementally.
**NOTE**: This skill covers standard web platform defenses, focusing mostly on the browser. Applications still require comprehensive server-side security, authorization models, and input validation.
## Table of Contents
- When to apply this skill
- Phase 1: Quick Wins & Obvious Anti-Patterns
- 1.1 Secure Contexts
- 1.2 Avoid Dangerous DOM Sinks
- 1.3 Secure Cookies
- 1.4 Clickjacking Protection (Frame-Ancestors & X-Frame-Options)
- 1.5 Secure Window Messaging (postMessage)
- Phase 2: Discovery & Data Collection (Prerequisites)
- 2.1 Inspect the Application
- 2.2 Deploy Report-Only Policies
- 2.3 Data Hygiene for Reports
- 2.4 Automated Discovery via Browser APIs and DevTools
- Phase 3: Interpreting Results & Enforcement
- Core enforcement (data-driven rollouts)
- 3.1 Analyzing CSP Reports
- 3.2 Transitioning to CSP Enforcement
- 3.3 Trusted Types Enforcement
- 3.4 Cross-Origin Opener Policy (COOP)
- 3.5 Cross-Origin Resource Policy (CORP)
- 3.6 Cross-Origin Isolation
- 3.7 Fetch Metadata (Resource Isolation)
- Companion policies (deploy in parallel)
- HTTP Strict Transport Security (HSTS)
- X-Content-Type-Options
- Referrer Policy
- Permissions Policy
- Subresource Integrity (SRI)
- Cross-Origin Resource Sharing (CORS)
- Clear-Site-Data (Logout)
## When to apply this skill
The right starting point depends on the application:
- **Retrofitting an existing app**: Always start at Phase 1. Strict policies applied without discovery will break the app. Treat Phase 2 (report-only) as a prerequisite for any Phase 3 enforcement.
- **Greenfield app or new feature**: You can adopt Phase 3 enforced policies directly, but still wire up reporting from day one.
- **SaaS template / framework defaults**: Ship Phase 1 hygiene and Phase 3 policies enabled by default, with Phase 2 reporting on so downstream users can detect regressions.
If you are unsure which case applies, default to Phase 1 → 2 → 3 in order.
**Focusing on Leverage**: While Phase 1 and 2 establish baseline hygiene and data gathering, Phase 3 core enforcement represents the highest-leverage security work. Specifically, Injection/XSS mitigation through CSP (§3.2) and Trusted Types (§3.3) addresses the largest practical threat, while companion policies and isolation defenses provide important defense-in-depth.
## Phase 1: Quick Wins & Obvious Anti-Patterns
Before attempting to deploy global security policies, focus on code-level hygiene and immediate fixes that do not risk breaking the application.
### 1.1 Secure Contexts
- **DO**: Deliver resources over HTTPS to protect against both passive and active network attackers.
- **DO**: Serve a header like `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` to force HTTPS whenever possible.
- **TIP**: In production rollout, start with a short `max-age` (e.g., 300 seconds) and incrementally increase to 1 year. A misconfigured HSTS with a long max-age can render the site permanently inaccessible until the cache expires in every browser that saw it.
### 1.2 Avoid Dangerous DOM Sinks
- **DO**: Prefer `textContent` or `innerText` over `innerHTML` when setting text content.
- **DO**: Use `setHTML` (part of the Sanitizer API) when available to safely insert HTML.
- **DO NOT**: Use `innerHTML` or `setHTMLUnsafe` with untrusted or unsanitized input.
- **DO**: Use DOMParser or create elements programmatically (`document.createElement`) instead of concatenating HTML strings.
**Dangerous sinks to grep for**: `innerHTML`, `outerHTML`, `document.write`, `eval`, `setTimeout` with a string argument, `script.src`.
**Code Pattern:**
```javascript
// Unsafe
element.innerHTML = `Hello, ${untrustedName}!`;
// Safe
element.textContent = `Hello, ${untrustedName}!`;
```
Trusted Types can enforce this pattern at runtime by blocking string assignments to dangerous sinks. Deploying it is a CSP enforcement step with real breakage risk — see §3.3.
### 1.3 Secure Cookies
Ensure new cookies are configured securely by default.
- **DO**: Prefer naming cookies with the `__Host-` prefix when they'll only be used by one domain. This requires the `Secure` and `Path=/` attributes to be set, and the `Domain` attribute to be omitted. This protects against same-site and network attackers.
- **DO**: Prefer naming cookies with the `__Secure-` prefix when `__Host-` isn't appropriate. This requires the `Secure` attribute, and protects against network attackers.
- **DO**: Explicitly set `SameSite=Lax` for standard first-party cookies.
- **DO**: If your application will be embedded as an iframe in third-party contexts, use `SameSite=None; Secure; Partitioned`.
- **DO NOT**: Rely on unpartitioned `SameSite=None` — these are being systematically blocked for tracking prevention.
```http
Set-Cookie: __Host-session_id=value; SameSite=Lax; HttpOnly; Secure; Path=/
Set-Cookie: third_party_var=value; SameSite=None; Secure; Partitioned
```
### 1.4 Clickjacking Protection (Frame-Ancestors & X-Frame-Options)
Clickjacking protection is easy to deploy, carries extremely low risk of breaking legitimate functionality, and provides immediate defense against malicious UI redressing.
- **DO**: Set the `X-Frame-Options: SAMEORIGIN` header to prevent other sites from embedding your pages in an iframe (or use `DENY` if you should never be embedded).
- **DO**: For fine-grained control, use `frame-ancestors 'self'` (or specified trusted domains) in your CSP header.
```http
X-Frame-Options: SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self' https://trusted-partner.com;
```
### 1.5 Secure Window Messaging (postMessage)
If your application communicates with other origins using `window.postMessage`, you must strictly validate the sender and receiver.
- **DO**: Always validate the `event.origin` of incoming messages on the receiver side using strict equality against a list of trusted origins. Do **not** trust wildcards (`*`) or unverified payloads.
- **DO**: Always specify a target origin (rather than the wildcard `*`) when calling `postMessage` to send sensitive data, ensuring only the intended origin can receive it.
- **DO**: Validate and sanitize the properties of incoming message payloads before performing operations or writing them to DOM sinks. Manual JSON serialization is unnecessary as `postMessage` handles object cloning internally.
```javascript
// Receiver (Safe - traditional string check)
window.addEventListener('message', (event) => {
if (event.origin !== 'https://trusted-origin.com') return;
const data = event.data;
if (data && data.action === 'update') {
// Process data safely
}
});
// Sender (Safe)
targetWindow.postMessage({ action: 'update' }, 'https://trusted-origin.com');
```
## Phase 2: Discovery & Data Collection (Prerequisites)
Do not blindly apply strict policies to an existing application. You must first understand the application's constraints by collecting data.
### 2.1 Inspect the Application
Before turning anything on, gather facts:
- **Grep for existing security headers** in server config, middleware, CDN/edge config, and meta tags: `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`, `X-Content-Type-Options`, `Permissions-Policy`, `Cross-Origin-*`, `Access-Control-*`, `Timing-Allow-Origin`, `Reporting-Endpoints`.
- **Enumerate inline scripts and styles** in server-rendered templates and static HTML — these will need nonces, hashes, or refactoring.
- **List third-party script origins** loaded by the app (analytics, ads, tag managers, CDNs). These dictate what `script-src` must allow or whether `'strict-dynamic'` is viable.
- **Identify popup-dependent flows**: OAuth, payment gateways, SSO. These constrain COOP choices.
- **Identify cross-origin embeds and embedders**: iframes the app loads, and sites that embed the app. These constrain COEP/CORP/`frame-ancestors`.
- **Enumerate required browser features**: List any features (camera, geolocation, microphone, fullscreen) used by the app or embedded third-party widgets to inform `Permissions-Policy`.
- **Identify dynamic dependencies**: Check if third-party scripts are versioned or if they receive silent updates, determining if `SRI` can be used.
- **Map cross-site integrations**: List all incoming Webhooks, cross-site APIs, or SSO redirect endpoints so `Fetch Metadata` resource isolation policies don't break them.
### 2.2 Deploy Report-Only Policies
Use "Report-Only" headers to identify potential breakages before they happen.
- **DO**: Use report-only headers to dry-run policies without enforcement. Standard report-only headers include:
- `Content-Security-Policy-Report-Only` for CSP rules.
- `Cross-Origin-Opener-Policy-Report-Only` for COOP isolation.
- `Cross-Origin-Embedder-Policy-Report-Only` for COEP isolation.
- `Document-Policy-Report-Only` for document features.
- **DO**: Define a `Reporting-Endpoints` header so violations have somewhere to go, and reference its name from `report-to`. Recommend setting an endpoint named `default`, which will automatically capture deprecation and crash reports.
- **DO**: Run report-only for long enough to cover real traffic patterns (typically days to weeks), not just synthetic testing.
**Example headers:**
```http
Reporting-Endpoints: default="https://reports.example/default", main-endpoint="https://reports.example/main"
Content-Security-Policy-Report-Only: script-src 'nonce-{RANDOM}' 'strict-dynamic' 'report-sample'; object-src 'none'; base-uri 'none'; report-to main-endpoint;
```
The `'strict-dynamic'`, `https:`, and `'unsafe-inline'` tokens together form a backwards-compatibility ladder: modern browsers honor `'strict-dynamic'` (nonce-propagating) and ignore the others; older browsers fall back to `https:`; very old browsers fall back to `'unsafe-inline'`. The fallbacks are harmless on any browser that supports a stricter token.
**Managing report false-positives**: Reporting endpoints receive a significant volume of false-positive violation reports caused by client-side middleware, aggressive browser extensions, ancient browsers, web crawlers, or antivirus scanners. When analyzing report-only logs, focus on high-frequency patterns from modern user-agents and filter out noise before making deployment decisions. Specifically:
- **Filter out noise**: Ignore reports sent by old browsers with known bugs triggering spurious violations, reports for markup known to be injected by popular browser extensions or client-side middleware (like identical reports seen across many distinct applications), and reports that do not contain enough information to debug.
- **Ignore low-volume reports**: If a policy is deployed, a low violation volume often indicates a false positive that can be safely ignored.
- **Leverage `'report-sample'`**: Always include `'report-sample'` in your `script-src` directives. This instructs the browser to include the first 40 characters of the violating script or inline code snippet in the violation report, which makes debugging much easier.
### 2.3 Data Hygiene for Reports
- **DO NOT**: Include sensitive data (PII, authentication tokens, session identifiers, query strings with secrets) in logs or violation reports. Mask or omit them at the edge before they reach the reporting endpoint.
### 2.4 Automated Discovery via Browser APIs and DevTools
- **Reporting API**: Use `Reporting-Endpoints` in combination with report-only headers (e.g., `Content-Security-Policy-Report-Only`, `Document-Policy-Report-Only`) to have the browser automatically post violations to your server.
- **Browser DevTools**: Use the **Issues Tab** in modern browsers (e.g., Chrome DevTools). It automatically surfaces blocked resources, mixed content, third-party cookie deprecation warnings, and feature policy violations without you having to crawl the codebase manually.
## Phase 3: Interpreting Results & Enforcement
After collecting data, decide how to proceed with enforcement. Phase 3 has two tracks that run in parallel, not in sequence:
- **Core enforcement (data-driven rollouts)** — high-breakage-risk policies that depend on Phase 2 report-only data. These are the rollouts you stage and watch.
- **Companion policies (deploy in parallel)** — lower-risk headers that can be turned on alongside or before the core work, with little or no Phase 2 discovery required.
### Core enforcement (data-driven rollouts)
#### 3.1 Analyzing CSP Reports
When reviewing CSP violation reports, first separate the noise (per §2.2) from legitimate application issues. For violations that appear to be caused by an incompatibility in your application (usually those where the "Sample" or "Blocked URI" seem like legitimate scripts or assets that might be present in your markup):
- **Code Search**: Search your codebase for the offending script source, URL, or hash to see if it is present in your code, dynamic server templates, or static HTML files.
- **Console Auditing**: Open the page that triggered the violation (the "Document URI" in the report) using the same browser, and check the developer tools/console for CSP violations while exercising as much application functionality as possible (some violations only trigger on specific user interactions).
Once filtered and triaged, analyze the reports against the following common scenarios:
- **Scenario**: Many violations for inline scripts.
- **Condition**: The app uses a framework that relies on inline scripts.
- **Decision**: Implement Nonces (server-rendered) or Hashes (static) before enforcing.
- **Scenario**: Violations for third-party analytics scripts.
- **Condition**: The scripts are required.
- **Decision**: Use `'strict-dynamic'` with a per-request nonce so the analytics loader can attach its dependencies. Do **not** add the analytics origin to a URL allowlist — domain allowlists are bypassable via open redirects, JSONP, and dependency injection on the listed origin.
- **Scenario**: Trusted Types violations on specific sinks.
- **Condition**: Legacy code paths still write strings to `innerHTML` etc.
- **Decision**: Refactor those sinks (per §1.2) or route them through a Trusted Types policy (§3.3) before enforcing.
#### 3.2 Transitioning to CSP Enforcement
Only move to enforced mode when:
1. Violations in the report-only logs have dropped to near zero or are accounted for.
2. Reporting remains wired up after the switch — keep `report-to` on the enforced header so regressions are visible.
**Key directives to set:**
- `script-src` with nonces or hashes — this is the core directive of any CSP and the primary mechanism to prevent XSS.
- `base-uri 'none'` to block `<base>` hijacking. Legacy directives like `object-src 'none'` can be omitted in modern, post-Flash web environments.
- *Optional but potentially breaking*: `default-src 'self'` is sometimes used as a fallback for unspecified fetch directives, but it dramatically complicates deployment and has little security value beyond `script-src`. It is generally safer to focus on robust `script-src` enforcement first.
- *Optional*: `form-action 'self'` prevents form submissions to attacker-controlled origins.
- *Optional*: `upgrade-insecure-requests` auto-upgrades subresource HTTP loads to HTTPS, though modern browsers largely auto-upgrade mixed content anyway.
**Enforced Header Example (CSP with reporting):**
```http
Reporting-Endpoints: main-endpoint="https://reports.example/main"
Content-Security-Policy: script-src 'nonce-{RANDOM}' 'strict-dynamic' 'report-sample'; object-src 'none'; base-uri 'none'; report-to main-endpoint;
```
HTML for nonce-based CSP:
```html
<script nonce="{RANDOM}" src="https://example.com/script.js"></script>
```
For static/cached HTML (SPAs) where a per-response nonce is not possible, use hash-based CSP: hash each inline script and list the hashes in `script-src`.
**Avoid**: URL allowlists like `script-src https://cdn.example.com` — they are easily bypassed by open redirects, JSONP endpoints, and dependency injection on the allowed origin.
#### 3.3 Trusted Types Enforcement
Trusted Types enforces the §1.2 source-level guidance at runtime: once enabled, the browser blocks string assignments to dangerous sinks unless they pass through a named policy.
- **Incremental Rollout Strategy**: While full enforcement carries real breakage risk, you do not need to do everything at once. A highly viable approach is to define and roll out a policy for a small portion of the application under refactoring, and slowly expand its usage as you replace sinks. This simplifies eventual global enforcement without short-term breakage risk.
- **Prerequisite**: Trusted Types requires framework cooperation. If the app's framework (or any third-party widget that writes to DOM sinks) does not produce `TrustedHTML` / `TrustedScript` values, the policy cannot be enforced without breaking that code. Audit framework support before starting the report-only rollout.
- **Prerequisite**: The code-level sink refactor from Phase 1 is a prerequisite for complete Trusted Types enforcement. (Standard CSP `script-src` enforcement, by contrast, does not police DOM sinks and can be deployed without refactoring them.)
- **DO**: Roll out via `Content-Security-Policy-Report-Only: require-trusted-types-for 'script'` first to find every offending sink.
- **DO**: Define a single named policy that performs sanitization (or escaping) and route all sink writes through it.
- **DO**: Move to full global `Content-Security-Policy: require-trusted-types-for 'script'` enforcement once the policy has been successfully integrated and violations in report-only logs drop to zero.
```javascript
if (window.trustedTypes && trustedTypes.createPolicy) {
const policy = trustedTypes.createPolicy('escapePolicy', {
createHTML: str => str.replace(/</g, '<').replace(/>/g, '>')
});
el.innerHTML = policy.createHTML(untrustedString);
}
```
#### 3.4 Cross-Origin Opener Policy (COOP)
Lowest-risk of the three. Deploy if the app is **not** an OAuth provider, payment processor, or otherwise expected to be reached from an opener.
- **DO**: Use `Cross-Origin-Opener-Policy: same-origin-allow-popups` — prevents a malicious opener from mounting XS-leaks attacks while still allowing OAuth and payment flows that *the app itself* initiates.
- **DO NOT**: Jump straight to `same-origin` unless you have explicitly verified that no integrations rely on cross-origin `window.opener` access.
#### 3.5 Cross-Origin Resource Policy (CORP)
Set CORP explicitly on each response based on whether it should be embeddable in other contexts. Two core benefits: it protects resources from malicious cross-origin reads, and ensures compatibility when pages request stronger client-side isolation.
- **DO**: Default to `Cross-Origin-Resource-Policy: same-origin` for app-internal resources (authenticated data, user session JSON, restricted internal scripts).
- **DO**: Use `same-site` for endpoints utilized across subdomains of the same eTLD+1.
- **DO**: Provide `cross-origin` exclusively for resources created for generic embedding or widely cached delivery (e.g., static shared assets or public CDNs).
#### 3.6 Cross-Origin Isolation
Highest deployment breakage risk. You only need to deploy this infrastructure if the application requires features relying on `SharedArrayBuffer` (e.g., WebAssembly multi-threading or shared memory architectures). If not required, skip this policy group.
- **Preferred path (Chromium environments)**: Enable `Document-Isolation-Policy: isolate-and-credentialless`. This provides client-side isolation comparable to COEP while instructing the browser to strip cookies and authentication credentials from non-CORS cross-origin resource fetches rather than blocking them outright. Note that this is supported primarily in Chrome (142+) and other vendors have not yet shown interest, so evaluate carefully based on your target audience. Apps that need to *block* cross-origin resources lacking explicit CORP opt-in (rather than load them with credentials stripped) can adopt `isolate-and-require-corp` instead. This is stricter and harder to deploy — it requires the same subresource audit as the cross-browser path below.
- **Cross-browser path (Complex enforcement)**: Require `Cross-Origin-Opener-Policy: same-origin` coupled with `Cross-Origin-Embedder-Policy: require-corp`. Every embedded subresource (images, styles, external media) MUST serve an explicit `Cross-Origin-Resource-Policy` header or the browser will prevent it from loading.
```http
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
```
#### 3.7 Fetch Metadata (Resource Isolation)
Server-side enforcement that uses `Sec-Fetch-*` request headers to reject suspicious cross-site requests. Requires the cross-site integration mapping from §2.1 before enforcing.
- **DO**: Implement a server-side resource isolation policy that checks `Sec-Fetch-*` headers and rejects `cross-site` requests for non-navigational endpoints.
- **DO**: Reject disallowed requests *before* authentication or authorization checks, so the response does not leak timing information about whether a resource or session exists.
- **DO**: Include `Vary: Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site` to prevent intermediate caches (CDNs) from serving cached responses to attackers.
- **CAUTION**: `same-site` trusts every subdomain under your eTLD+1. If any subdomain hosts user-generated content, a legacy app, or otherwise untrusted code, drop `same-site` from the allowlist and accept only `same-origin` and `none`.
- **CAUTION**: Misconfiguring these checks will block legitimate API requests coming from cross-site integrations, SSO handlers, or Webhooks. Ensure you log and test your `Sec-Fetch-*` constraints beforehand.
```javascript
app.use((req, res, next) => {
const site = req.get('Sec-Fetch-Site');
const mode = req.get('Sec-Fetch-Mode');
const dest = req.get('Sec-Fetch-Dest');
if (!site) return next(); // Fallback for legacy browsers
if (['same-origin', 'same-site', 'none'].includes(site)) return next();
// Allow standard navigate GET requests (link clicks)
if (site === 'cross-site' && mode === 'navigate' && req.method === 'GET' && !['object', 'embed'].includes(dest)) {
return next();
}
res.status(403).send('Forbidden');
});
```
### Companion policies (deploy in parallel)
These carry significantly lower breakage risk than the core enforcement track. They can be deployed alongside — or before — the CSP and isolation rollouts.
#### HTTP Strict Transport Security (HSTS)
- **DO**: `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` to force HTTPS.
- **TIP**: In production rollout, start with a short `max-age` (e.g., 300 seconds) and incrementally increase to 1 year. A misconfigured HSTS with a long max-age can render the site permanently inaccessible until the cache expires in every browser that saw it.
#### X-Content-Type-Options
- **DO**: Set `X-Content-Type-Options: nosniff` to block MIME-type sniffing.
- **DO**: Ensure the server serves correct `Content-Type` headers for all resources (`application/javascript` for scripts, `application/json` for APIs, `text/html` for documents, etc.) so the browser can strictly enforce the `nosniff` constraint.
#### Referrer Policy
- **DO**: Use `Referrer-Policy: strict-origin-when-cross-origin` as a safe default.
#### Permissions Policy
- **DO**: Disable unused browser features (camera, geolocation, microphone) for the page and iframes using Structured Fields syntax.
- **DO**: When delegating features to an iframe, use the `allow` attribute in HTML *in addition* to the header.
- **CAUTION**: Unintentionally blocking a delegated feature will cause silent failures in third-party widgets (like embedded video players or payment gateways). Audit third-party dependencies before blocking.
```http
Permissions-Policy: camera=(), geolocation=(), microphone=()
```
```html
<iframe src="https://trusted-video.com/player" allow="fullscreen; camera"></iframe>
```
#### Subresource Integrity (SRI)
- **DO**: Use the `integrity` attribute with a cryptographic hash (preferring `sha256` or `sha512`) when loading third-party scripts, combined with `crossorigin="anonymous"`.
- **DO**: Ensure the server/CDN sends an appropriate `Access-Control-Allow-Origin` header so the browser can compute the hash.
- **DO NOT**: Use SRI for dynamic or unversioned assets — silent updates will cause script execution to fail. SRI is strictly for immutable, versioned assets.
```html
<script src="https://cdn.example.com/lib.js" integrity="sha256-H8df...39v" crossorigin="anonymous"></script>
```
#### Cross-Origin Resource Sharing (CORS)
CORS is a permission grant, not a defense — it tells the browser which cross-origin reads to allow. The risk is misconfiguring it as too permissive.
- **DO**: Validate the `Origin` header on the server and set `Access-Control-Allow-Origin` dynamically to specific origins (rather than wildcard `*`).
- **DO NOT**: Use wildcard `*` for `Access-Control-Allow-Origin` if `Access-Control-Allow-Credentials: true` is required — the browser will reject the response.
- **DO**: Handle preflight (`OPTIONS`) requests by returning appropriate headers before processing data.
```http
Access-Control-Allow-Origin: https://trusted-app.com
Access-Control-Allow-Credentials: true
```
#### Clear-Site-Data (Logout)
- **DO**: Use `Clear-Site-Data` on logout endpoints to ensure complete session termination.
```http
Clear-Site-Data: "cookies", "storage", "cache"
```
guides/ui-atoms/carousel-slide-effects.md
# Build Carousel Slide Effects
Carousel slide effects are a great way to add visual interest to a carousel. As the user scrolls through the slides, each slide can animate as it enters, centers, and exits the scrollport. For example, the slides can fade in and out, rotate, or scale in size. This creates a dynamic and engaging user experience. Unlike simple entry/exit animations, this effect uses a single, continuous animation to control the slide's appearance across the entire scrollport.
## How to implement
Here’s how to create carousel slide effects:
1. **Create a scroller:** This element will act as the container for your carousel slides. In this example it uses `overflow-x: scroll` to allow horizontal scrolling.
```html
<ul class="scroller">
<li class="entry">1</li>
<li class="entry">2</li>
<li class="entry">3</li>
…
</ul>
```
```css
.scroller {
overflow-x: scroll;
}
```
2. **Define the animation:** Create a CSS animation that defines the different states of your slides as they traverse the scrollport. You can define keyframes for any part of the animation. For example, you can define a state for when the slides are in the center of the scrollport by including a `50%` keyframe. In this example, the `scale` property makes the slides grow as they approach the center and shrink as they move away.
```css
@keyframes animate {
0% {
scale: 0.5;
}
50% {
scale: 1;
}
100% {
scale: 0.5;
}
}
```
3. **Apply the animation and `view-timeline`:** Attach the animation to the carousel slides and link it to a `view-timeline` that tracks the element as it scrolls through the container.
```css
.scroller > * {
animation: animate auto linear both;
animation-timeline: view(inline);
}
```
By default, `view()` tracks the element on the `block` axis. If you need to track it on the `inline` axis, you can use `view(inline)`.
## Example code
This code animates the carousel items of a horizontal scroller on scroll using an **anonymous view-timeline**:
```css
@keyframes animate {
0% {
scale: 0.5;
}
50% {
scale: 1;
}
100% {
scale: 0.5;
}
}
.scroller > * {
/* Applies the animation using an `auto` duration */
animation: animate auto linear both;
/* Sets the animation timeline to use an anonymous view progress timeline, tracking the element's progress through the scroller on the inline axis */
animation-timeline: view(inline);
}
```
This code animates the carousel items of a horizontal scroller on scroll using a **named view-timeline**:
```css
@keyframes animate {
0% {
scale: 0.5;
}
50% {
scale: 1;
}
100% {
scale: 0.5;
}
}
/* This creates a named view-timeline on each carousel item. The timeline is used to drive the animation that is applied on the same element. */
.scroller > * {
/* Applies the animation using an `auto` duration */
animation: animate auto linear both;
/* Defines a named view progress timeline, tracking the element's progress through the scroller on the inline axis */
view-timeline: --item inline;
/* Sets the animation timeline to use the named view progress timeline defined above */
animation-timeline: --item;
}
```
## Best Practices
When using scroll-driven animations, it's important to follow a few best practices to ensure a smooth and accessible experience:
- **DO** include feature detection: Not all browsers support scroll-driven animations. Use `@supports ((animation-timeline: view()) and (animation-range: entry))` to check for support and provide a fallback for browsers that don't support it.
- The `(animation-range: entry)` check **MUST** be included here, to filter out browsers with only partial support.
- **DO NOT** use the `scroll-timeline-polyfill` package for the fallback strategy as it is not feature complete and has a lot of known issues.
- If the animation is only considered to be decorative, opt for Progressive Enhancement and **DO NOT** provide a fallback.
- **DO** respect user preferences: Some users prefer to have less motion on the web. Use the `prefers-reduced-motion` media query to disable or reduce your animations for these users.
- **DO** try to animate only performant CSS properties: For the smoothest animations, stick to animating properties that can be handled by the browser's compositor thread, such as `transform` and `opacity`. Animating other properties like `width` or `height` can lead to performance issues.
- **DO** use the correct declaration order: When using the `animation` shorthand property, declare `animation-timeline` and `animation-range` *after* it to prevent the shorthand from resetting the timeline.
Prefer a named `view-timeline` when multiple DOM elements need to animate based on the same timeline, or when you need to animate children of the element that has the `view-timeline` defined on it. If the element that you animate is also the element that defines the `view-timeline`, you can use an anonymous view-timeline using `view()`.
When using the `view()` function to create a scroll-driven animation:
- **OPTIONAL** be explicit about the axis to track: When not targeting the default `block` axis (such as in a horizontal scroller), be explicit about which axis to track with `view(block)` or `view(inline)`.
When using the `view-timeline` property to create a scroll-driven animation:
- **DO** use a CSS `<dashed-ident>` for the name (e.g. `view-timeline: --my-custom-name`)
- **OPTIONAL** be explicit about the axis to track: When not targeting the default `block` axis (such as in a horizontal scroller), be explicit about which axis to track with `view-timeline-axis`.
- **DO** make sure the scope of the lookup works: When the element that is declaring the `view-timeline` is not a flat tree ancestor of the animated element, hoist up the visibility of the `view-timeline`’s name by using `timeline-scope` on a shared ancestor.
## Browser support and fallback strategies
Scroll-driven animations has limited availability.
Supported by: Chrome 115 (Jul 2023), Edge 115 (Jul 2023), and Safari 26 (Sep 2025).
Unsupported in: Firefox.. Therefore, a fallback strategy is typically required.
For browsers that do not support scroll-driven animations, you can use a fallback to recreate the visual effects. The fallbacks are typically built with either a scroll listener (for ScrollTimeline effects) or the IntersectionObserver API (for ViewTimeline effects).
In browsers with built-in support for scroll-driven animations, ALWAYS use the native CSS implementation as those are more performant.
Note that not every effect can be recreated using the fallbacks approach.
For this use-case specifically, the following script applies the fallback for browsers that do not support scroll-driven animations. It uses the Web Animations API (`Element.animate()`) to create a paused animation for each item in the carousel. It then listens to the `scroll` event on the scroller and updates the `currentTime` of each animation based on the item's scroll progress within the scroller.
```js
// Fallback for browsers that don't support scroll-driven animations
if (!CSS.supports('(animation-timeline: view()) and (animation-range: entry)')) {
const scroller = document.querySelector('.scroller');
const entries = document.querySelectorAll('.entry');
// Create a map to store animations
const animations = new Map();
entries.forEach(entry => {
const animation = entry.animate(
{
scale: ['0.5', '1', '0.5']
},
{
duration: 1, // We'll control the time ourselves
fill: 'both'
}
);
animation.pause();
animations.set(entry, animation);
});
// Update animations on scroll
const tick = () => {
const scrollerRect = scroller.getBoundingClientRect();
entries.forEach(entry => {
const animation = animations.get(entry);
if (!animation) return;
const entryRect = entry.getBoundingClientRect();
const progress = (entryRect.left + entryRect.width / 2 - scrollerRect.left) / scrollerRect.width;
animation.currentTime = progress;
});
};
scroller.addEventListener('scroll', tick);
tick();
}
```
guides/ui-atoms/component-specific-light-dark-theme.md
# Component-specific light/dark themes
While more commonly set on the root, the `color-scheme` property can be set on individual elements to force them into a different color scheme from the rest of the page.
This can be useful for components that must always be viewed in a specific color scheme (e.g. always in dark or light mode).
Example use cases include:
- Elements that are often in dark mode even on light mode pages for aesthetic reasons, e.g. code blocks, media players, photo galleries
- Areas that contain media designed for a light background (e.g. images, videos, illustrations, print previews) can be set to light mode even if the rest of the page is in dark mode.
- Elements whose color-scheme is controlled by a user-level setting, such as component previews
- Embeds that don't support both light and dark modes
- Design tools, maps, visualizations, games etc.
## When to change colors vs. when to force `color-scheme`?
Not every element that uses lighter text on darker background in light mode or darker text on lighter background in dark mode needs a different `color-scheme`.
For example, a primary button may be rendered as blue with white text in light mode, but that does not warrant a `color-scheme: dark`.
As a rule of thumb, typically elements using a different `color-scheme` are complex surfaces establishing their own visual context, rather than simple shallow containers.
When considering using a different `color-scheme` on an element, ask yourself:
- Should built-in browser UI that is not otherwise customized (e.g. form controls, scrollbars, etc) use that color-scheme or adapt to the page's color-scheme? -> if the former, don't use `color-scheme`.
- Should any `light-dark()` colors resolve like they do for the rest of the page or based on the override? -> if the former, don't use `color-scheme`.
- Should descendants be in that `color-scheme`? If not, don't use `color-scheme`.
## Basic implementation
Component-specific overrides are typically (though not strictly necessarily) used on pages that also support multiple color schemes via a global `color-scheme`.
For implementing page-wide dark mode well, see `dark-mode` (via `npx -y modern-web-guidance@latest retrieve "dark-mode"`).
Once a page-wide `color-scheme` is in place, and you are using color tokens sensitive to it (e.g. via `light-dark()`), you can simply set `color-scheme` on specific components to override the color mode for that subtree:
```css
pre, code, .dark {
color-scheme: dark;
}
```
Note that some browsers automatically adapt components to a different color scheme anyway.
To force the specified color scheme in all cases, use `only`, i.e. `color-scheme: only dark;` instead of `color-scheme: dark;`.
### Adapting non-color values
`light-dark()` currently only works for colors.
## Best practices
- **MANDATORY**: Do not set `color-scheme` on elements without a background, as that risks mixing background and text color pairs from different color-schemes, resulting in unreadable text.
- **OPTIONAL**: While it is easier to reuse the same color pairs as the page-wide dark mode, we _can_ define different color pairs for these components. For example, we may want a dark mode component used in a light mode page to be a little less dark than when the same dark mode component is used in a page that is overall in dark mode.
## Known issues to be aware of
### Important gotcha: Inheritance of `light-dark()` colors
**`light-dark()` resolves at computed value time.**
This means that any inherited `<color>` properties set to a `light-dark()` color will only pass down one of the two colors to their descendants, not the `light-dark()` expression itself.
This includes:
- Built-in color properties that inherit, such as `color`, `accent-color`, `fill`, `stroke`, `text-shadow`, `caret-color`
- Any registered inheritable custom properties with `syntax: <color>` and `inherits: true`
- Any other `<color>` property set to `inherit`
This means you should:
- **NOT** register custom properties meant to hold *design tokens* (e.g. `--surface-color`) as `<color>`. Tokens need to keep their `light-dark()` expression live so descendants can re-resolve them under a different `color-scheme`.
- When setting `color-scheme` on an element, re-specify any inherited `<color>` properties that may have been set to `light-dark()` values (directly or via design tokens), even if that's to the same design token.
- **NOT** use `inherit` on `<color>` properties on elements with a `color-scheme` override (fine to use on their descendants).
- **DO** use registered `<color>` properties for the *opposite* use case: when you deliberately want to snapshot the ancestor's resolved color and prevent it from re-resolving under the descendant's `color-scheme`. For example, capturing the page background to use elsewhere.
- If you need to animate a color, use a separate `@property`-registered `<color>` property on the element being animated (registration is required for color interpolation) — this is not a design token, but a per-element animation target, so it does not conflict with the rule above.
Example:
```css
:root {
--accent-color: light-dark(blue, skyblue);
--surface-color: light-dark(white, #222);
--text-color: light-dark(#111, white);
color-scheme: light dark;
accent-color: var(--accent-color);
color: var(--text-color);
}
body {
/* --surface-color dynamically switches despite being inherited because --surface-color is not registered */
background: var(--surface-color);
}
pre, code {
color-scheme: dark;
background: var(--surface-color);
/* Without this, accent-color would be blue, not skyblue! */
accent-color: var(--accent-color);
/* Without this, text-color would be #111, not white! */
color: var(--text-color);
}
```
### Issues to be aware of when using color-scheme
- Chrome and Firefox respect `color-scheme` for iframes: they render embedded pages in the correct color scheme and adjust the embedded page's `prefers-color-scheme` media query to reflect the embedding context's `color-scheme`. Safari does not, and resolves `prefers-color-scheme` to the system setting even inside iframes.
- **If you control both parent and iframe:** pass the parent's color scheme to the iframe explicitly — via a URL parameter (`?theme=dark`) at iframe construction time, or via `postMessage()` (which also lets you react to runtime changes). In the iframe, set a class on `<html>` (and/or `color-scheme` on `:root`) from that signal instead of relying on `prefers-color-scheme`.
- **If you only control the embedded page:** there is no reliable way to detect the embedding context's `color-scheme` from inside the iframe in Safari. Expose an explicit theme parameter on your embed API (e.g. a query string or `postMessage` protocol) and document it for embedders.
## Fallback strategies
### Fallbacks & browser support for color-scheme
Baseline status for color-scheme: Widely available. It's been Baseline since 2022-02-03.
Supported by: Chrome 98 (Feb 2022), Edge 98 (Feb 2022), Firefox 96 (Jan 2022), and Safari 13 (Sep 2019).
The `color-scheme` property is **progressive enhancement**.
Browsers that do not support it will ignore this property and use their default light-mode UI.
To adapt to the user's preferences in older browsers, use `prefers-color-scheme` media queries to provide different colors when dark mode is preferred.
- DO use the media query to switch custom properties on `:root` or `html`
- Avoid using the media query on individual components unless the component requires a very specific type of dark mode customization beyond colors.
```css
:root {
/* Define brand colors for each mode */
--color-brand-light: #0056b3;
--color-brand-dark: #00e5ff;
--color-brand: var(--color-brand-light);
/* MANDATORY: Fallback for browsers without light-dark support */
@media (prefers-color-scheme: dark) {
--color-brand: var(--color-brand-dark);
}
/* Ignored in older browsers */
color-scheme: light dark;
}
button.primary {
background-color: var(--color-brand);
}
```
### Fallbacks & browser support for light-dark()
Baseline status for light-dark(): Newly available. It's been Baseline since 2024-05-13.
Supported by: Chrome 123 (Mar 2024), Edge 123 (Mar 2024), Firefox 120 (Nov 2023), and Safari 17.5 (May 2024).
For browsers that support `color-scheme` but not yet `light-dark()`, light and dark versions of colors should first be defined as custom properties, and the `prefers-color-scheme` media query should be used to set colors for the respective mode like in the example below:
```css
:root {
/* Define browser UI accent color for each mode */
--brand-accent-light: #0056b3;
--brand-accent-dark: #00e5ff;
--accent-color: var(--brand-accent-light);
/* MANDATORY: Fallback for browsers without light-dark support */
@media (prefers-color-scheme: dark) {
--accent-color: var(--brand-accent-dark);
}
/* OPTIONAL: use light-dark() for more control of built-in UI colors */
@supports (color: light-dark(white, black)) {
--accent-color: light-dark(var(--brand-accent-light), var(--brand-accent-dark));
}
/* MANDATORY: Automatically adapt native UI to user system preferences */
color-scheme: light dark;
/* Example inherited color property */
accent-color: var(--accent-color);
}
pre, code {
color-scheme: dark;
/* **Mandatory**: any inherited color properties must be set again, even if to the same design tokens */
accent-color: var(--accent-color);
}
```
guides/ui-atoms/position-aware-tooltips.md
# Position Aware Tooltips
When building tooltips or popovers with CSS Anchor Positioning, the browser can automatically "flip" the element to a fallback position if it would otherwise overflow the viewport. When this happens, you may want to adjust the style of the positioned content, for instance to reposition an arrow that points from the positioned content to the anchor.
**Anchored Container Queries** solve this by allowing you to query the active positioning state of an element and apply styles accordingly.
## The problem
Imagine a tooltip that appears above its anchor by default. It has a "down" arrow at the bottom. If the user scrolls and the tooltip flips to appear *below* the anchor, the arrow is now pointing the wrong way and is on the wrong side of the tooltip.
## The solution: Anchored Container Queries
By setting `container-type: anchored` on your positioned element, you turn it into a query container that knows about its own anchor-positioned state. You can then use the `@container anchored()` query to update its descendants or pseudo-elements.
### 1. Create the tooltip and trigger
Use the Popover API to create a tooltip. This creates an implicit anchor connection that can be used for positioning.
```html
<button popovertarget="tooltip" id="anchor" aria-describedby="tooltip">anchor</button>
<div id="tooltip" popover role="tooltip"></div>
```
Reset the popover inset and margin styles for use with anchor positioning, but only if anchor positioning is supported.
```css
@supports (anchor-name: --my-anchor) {
[popover] {
inset: auto;
margin: unset;
}
}
```
### 2. Set up the container
Apply `container-type: anchored` to the element being positioned. This element must also have `position-try-fallbacks` defined to enable the flipping behavior.
```css
#tooltip {
position: fixed;
position-area: block-start;
position-try-fallbacks: flip-block;
/* Enable anchored container queries */
container-type: anchored;
}
```
### 3. Style based on the fallback
Use `@container anchored(fallback: <value>)` to apply styles when a specific fallback is active.
Like all container queries, `@container` can only style **descendants** of the container. A common strategy to create the arrows is with the `::before` and `::after` pseudo-elements, which are treated as descendants and can be styled directly. However, to style the tooltip itself (as seen in step 4), we will add a child element to the tooltip, and create the arrow in its `::before` pseudo-element.
```html
<div id="tooltip" popover role="tooltip">
<div class="tooltip-content">Tooltip</div>
</div>
```
```css
.tooltip-content::before {
/* Default "down" arrow for the 'top' position */
content: "▼";
position: absolute;
inset-block-end: 0;
inset-inline-start: 1rem;
}
/* Update to an "up" arrow when the 'flip-block' fallback (bottom) is active */
@container anchored(fallback: flip-block) {
.tooltip-content::before {
content: "▲";
inset-block-start: 0;
inset-block-end: auto;
}
}
```
## 4. Styling the container itself
If you need to change properties on the container itself (like `margin` or `background-color`) when it flips, you should use an **inner wrapper element**.
1. Apply `container-type: anchored` to the outer positioned element.
2. Target the inner element inside the `@container` block.
```css
@container anchored(fallback: flip-block) {
.tooltip-content {
border-radius: 0 0 .5rem .5rem;
margin-block-start: 0.25rem;
}
}
```
## Best practices
- **Prefer logical fallbacks**: Use keywords like `flip-block` and `flip-inline` in `position-try-fallbacks` for simpler queries that handle RTL and different writing modes automatically.
- **Use pseudo-elements for arrows**: Tooltip arrows are purely decorative and are perfect candidates for `::before` or `::after`, which can be styled via anchored container queries without extra DOM.
## Fallback strategies
Anchor position container queries has limited availability.
Supported by: Chrome 143 (Dec 2025) and Edge 143 (Dec 2025).
Unsupported in: Firefox and Safari.
Positioning the arrow based on the applied fallback is a progressive enhancement, and there is not another way of reacting to the fallback position. To hide the arrow in browsers that don't support anchor position container queries, test for CSS support with `@supports (container-type: anchored)`.
```css
@supports (container-type: anchored) {
.tooltip-content::before {
content: "▼";
}
}
```
### Fallbacks & browser support for Popover
Baseline status for Popover: Newly available. It's been Baseline since 2025-01-27.
Supported by: Chrome 116 (Aug 2023), Edge 116 (Aug 2023), Firefox 125 (Apr 2024), Safari 17 (Sep 2023), and Safari iOS 18.3 (Jan 2025).
The Popover API is mostly **progressive enhancement**, but its defining behaviors — top-layer promotion, light-dismiss, and `popovertarget` invocation — have no CSS-only equivalent. Older browsers need a polyfill, or a manual fallback if you would rather not ship one.
**Polyfill:** To support the `popover` attribute in older browsers, conditionally load [`@oddbird/popover-polyfill`](https://github.com/oddbird/popover-polyfill). **MANDATORY:** Feature detect by checking for the `popover` property on `HTMLElement.prototype`, and load the polyfill **only** when native support is missing — do NOT load it unconditionally.
With a bundler or import map:
```js
// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
import("@oddbird/popover-polyfill");
}
```
Without a bundler, import from a CDN inside a `<script type="module">`:
```html
<script type="module">
if (!("popover" in HTMLElement.prototype)) {
import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
}
</script>
```
**Styling caveat:** The polyfill cannot define the real `:popover-open` pseudo-class, so it applies a `.\:popover-open` class instead. **MANDATORY:** Combine the two with `:is()` or `:where()`, otherwise browsers that lack `:popover-open` discard the entire rule:
```css
[popover]:is(:popover-open, .\:popover-open) {
display: block;
}
```
Alternatively, for a legacy fallback without a polyfill, use `position: fixed` and manually calculate coordinates via `getBoundingClientRect()` or rely on default positioning with `inset: auto` if that's acceptable for the use case.
Browsers without support for the Popover API also do not support anchor positioning, so the tooltip will appear in the center of the screen.guides/ui-atoms/pull-to-reveal.md
# Pull to Reveal
"Pull to reveal" is a UI pattern where content (such as a search bar or refresh control) is hidden above the top of a scrollable area on initial load, and the user can pull down (scroll up) to reveal it. This pattern is commonly used in mobile apps and web apps for search bars, filters, and other secondary controls that should be accessible but not immediately visible.
The CSS property `scroll-initial-target` offers a declarative, CSS-only way to implement this pattern. By setting `scroll-initial-target: nearest` on the main content element, the scroll container will render with the hidden content scrolled out of view. Previously, developers relied on JavaScript (`Element.scrollIntoView()`) or URL fragment identifiers (`#content-id`) to achieve this, both of which have limitations and are tricky to implement.
## How to Implement
To implement a pull-to-reveal pattern:
1. **Ensure a scroll container:** The target element must be inside a scroll container (an element with overflow that allows scrolling, such as `overflow: auto`). This can be any ancestor element, including the root `<html>` element.
2. **Define the hidden element:** Place the content you want to hide (e.g., a search bar) as the first descendant inside the scroll container. This element will be scrolled out of view on initial load.
3. **Define the main content:** Place the main content element immediately after the hidden element. This is the element the user should see first.
4. **Target the main content:** Apply `scroll-initial-target: nearest` to the main content element so the scroll container renders with it scrolled into view, hiding the element above it.
5. **Add scroll snapping:** To ensure the hidden element is always either fully visible or fully hidden (and doesn't rest in a partially-scrolled state), add `scroll-snap-type: y mandatory` to the scroll container and `scroll-snap-align: start` to both the hidden element and the main content element.
## Example Code: Pull to Reveal Search
```css
/**
* ANCESTOR: Define the scroll container.
* Scroll snapping ensures the search bar is always
* either fully visible or fully hidden.
*/
.scroll-container {
height: 100vh;
overflow-y: auto;
scroll-snap-type: y mandatory;
}
/**
* HIDDEN ELEMENT: The search bar hidden above the fold on load.
* It has scroll-snap-align so it snaps into place when pulled down.
*/
.search-bar {
height: 60px;
scroll-snap-align: start;
}
/**
* MAIN CONTENT + TARGET: The element the user sees first.
* scroll-snap-align makes it a valid snap point.
* scroll-initial-target tells the browser to scroll here on
* initial render, hiding the search bar above it.
*/
.main-content {
scroll-snap-align: start;
scroll-initial-target: nearest;
}
```
## Strategic Implementation & Best Practices
- **DO** use `scroll-initial-target: nearest` when you want to draw the user's attention to a specific part of a scrollable area upon load and intentionally hide peripheral UI units like a search bar at the very top.
- **DO NOT** confuse this with accessibility focus. This property only moves the **visual** viewport; it does not move the keyboard focus. You must manually manage `element.focus()` if the target is intended to be the starting point for keyboard users.
- **DO NOT** use this if you need a smooth "scrolling" animation on load; this property is discrete and sets the position instantly during the layout phase.
- **DO NOT** set `scroll-initial-target` on multiple elements within the same scrollable container. If multiple elements specify `scroll-initial-target: nearest`, the browser selects the one that appears first in the DOM tree order.
- **DO** account for the **Precedence Hierarchy**: A URL fragment (e.g., `example.com/#top`) and the container-level `scroll-start` property both take precedence over `scroll-initial-target`.
## Fallback Strategy
scroll-initial-target has limited availability.
Supported by: Chrome 133 (Feb 2025) and Edge 133 (Feb 2025).
Unsupported in: Firefox and Safari.
For browsers that do not yet support the API, use a JavaScript fallback. Note that for pulling content to reveal, you want the main content to be bound to the `start` (top) of the container.
```javascript
/**
* Progressive Enhancement Fallback
*/
document.addEventListener("DOMContentLoaded", () => {
// Check for native CSS support
if (!CSS.supports("scroll-initial-target", "nearest")) {
const targetContent = document.querySelector('.main-content.target');
if (targetContent) {
// Use behavior: "instant" to mimic the native CSS behavior
// 'block: start' should match your CSS 'scroll-snap-align' (or expected top position)
targetContent.scrollIntoView({ behavior: 'instant', block: 'start' });
}
}
});
```
guides/ui-atoms/resilient-context-menus-and-nested-dropdowns.md
# Resilient Context Menus And Nested Dropdowns
A revealed action panel or popover button group is a useful pattern for users to access additional functionality while taking up minimal space. This overlay pattern comes with layout complexity, as the panel must remain tethered to a trigger element while adapting to viewport constraints. Traditionally, this required complex JavaScript libraries (like Popper.js or Floating UI) to calculate positions and handle collisions.
CSS Anchor Positioning provides a declarative, performance-optimized way to handle these relationships entirely in CSS, allowing browsers to manage the positioning and overflow logic natively.
> [!NOTE]
> This guide demonstrates anchor-positioning and popover mechanics — it does not prescribe a specific accessible UI pattern. The trigger and panel below are shown as a plain **button-revealing-a-button-group**. If you need a true ARIA menu (`role="menu"` with arrow-key navigation), a combobox, a disclosure widget, or any other named pattern, layer that pattern's full semantics and keyboard contract on top of the positioning techniques shown here.
### 1. Define the Button and Panel Relationship
The first step is to create a trigger button that opens the overlay container using the Popover API.
**MANDATORY Accessibility Distinction:** This pattern explicitly models a **button group revealed inside a popover** rather than a true ARIA menu. Do not apply `role="menu"` or `role="menuitem"` unless you fully implement the corresponding keyboard navigation contract (such as handling spatial arrow-key navigation between items). For the same reason, do not add `aria-haspopup` to the trigger — its value (`menu`, `listbox`, `tree`, `grid`, or the legacy `true`) is a promise that the target exposes a matching role, which this pattern does not.
```html
<button popovertarget="action-panel">
Open Actions
</button>
<!-- Use the Popover API (`popover="auto"`) for the overlay to ensure it is placed in the top layer and handled accessibly by the browser. -->
<div id="action-panel" popover="auto" class="panel">
<button class="action-item" type="button">Edit</button>
</div>
```
This creates an *implicit* anchor association between the button and the panel, so that the panel can be positioned relative to the button.
### 2. Positioning with `position-area`
Instead of manual `top`/`left` offsets, use `position-area` to place the target on a 3x3 grid relative to the anchor.
```css
.panel {
/*
Position the panel below the anchor (block-end),
aligned to the start of the anchor and spanning to its end (span-inline-end).
*/
position-area: block-end span-inline-end;
/* Reset insets to allow the grid to take control */
inset: auto;
}
```
Prefer logical keywords (`span-inline-end`, `block-start`) over physical ones (`left`, `top`) to support RTL and different writing modes automatically.
**MANDATORY**: Do not mix physical and logical keywords in `position-area`.
### 3. Implement Edge-Resilience (Fallbacks)
To prevent the panel from being cut off at the edge of the screen, define "try tactics" that the browser should attempt if the default position overflows.
```css
.panel {
/*
If the panel overflows the bottom, flip it to the top (flip-block).
If it overflows the inline edges, flip it horizontally (flip-inline).
*/
position-try-fallbacks: flip-block, flip-inline;
}
```
## Fallback strategies
### Fallbacks & browser support for Popover
Baseline status for Popover: Newly available. It's been Baseline since 2025-01-27.
Supported by: Chrome 116 (Aug 2023), Edge 116 (Aug 2023), Firefox 125 (Apr 2024), Safari 17 (Sep 2023), and Safari iOS 18.3 (Jan 2025).
The Popover API is mostly **progressive enhancement**, but its defining behaviors — top-layer promotion, light-dismiss, and `popovertarget` invocation — have no CSS-only equivalent. Older browsers need a polyfill, or a manual fallback if you would rather not ship one.
**Polyfill:** To support the `popover` attribute in older browsers, conditionally load [`@oddbird/popover-polyfill`](https://github.com/oddbird/popover-polyfill). **MANDATORY:** Feature detect by checking for the `popover` property on `HTMLElement.prototype`, and load the polyfill **only** when native support is missing — do NOT load it unconditionally.
With a bundler or import map:
```js
// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
import("@oddbird/popover-polyfill");
}
```
Without a bundler, import from a CDN inside a `<script type="module">`:
```html
<script type="module">
if (!("popover" in HTMLElement.prototype)) {
import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
}
</script>
```
**Styling caveat:** The polyfill cannot define the real `:popover-open` pseudo-class, so it applies a `.\:popover-open` class instead. **MANDATORY:** Combine the two with `:is()` or `:where()`, otherwise browsers that lack `:popover-open` discard the entire rule:
```css
[popover]:is(:popover-open, .\:popover-open) {
display: block;
}
```
Alternatively, for a legacy fallback without a polyfill, use `position: fixed` and manually calculate coordinates via `getBoundingClientRect()` or rely on default positioning with `inset: auto` if that's acceptable for the use case.
Anchor positioning is not natively supported by any major browser yet.
To support browsers without anchor positioning, you must set a reasonable position. By default popovers are centered in the middle of the screen, which may work for your use case.
For some use cases, you may be able to use the `@oddbird/css-anchor-positioning` polyfill, which adds support for some anchor positioning use cases. It does not support implicit anchors, so you MUST add anchor names to the trigger. Additionally, `position-area` is not supported on popovers by the polyfill, so you MUST use `anchor()` on the desired insets.
```html
<!-- MANDATORY: Conditionally install the anchor positioning polyfill -->
<script type="module">
if (!("anchorName" in document.documentElement.style)) {
await import("https://unpkg.com/@oddbird/css-anchor-positioning");
}
</script>
```
```css
.panel {
/* Mandatory: use explicit anchor name */
position-anchor: --kebab-anchor;
/* Mandatory: use insets rather that position-area for positioning */
bottom: auto;
right: auto;
top: anchor(bottom);
left: anchor(left);
margin: 0;
}
```guides/ui-atoms/scroll-position-aware-elements.md
# Scroll Position Aware Elements
## Overview
Improve the user experience of floating buttons, like a "Back to Top" link, by showing them only when they are useful. This guide shows how to build these elements using CSS `container-scroll-state-queries`, which allows styling elements based on the scroll position of their container without relying on JavaScript scroll listeners or observers.
## Implementation
### 1. Establish the Scroll Container
The scroll container must be declared as a scroll-state query container.
```css
.scroller {
overflow-y: auto;
/* Establish this element as a scroll-state query container */
container-type: scroll-state;
}
```
### 2. Style the Floating Element
Place the element inside the container and style it. By default, it should be hidden.
```css
.back-to-top {
position: sticky;
bottom: 20px;
visibility: hidden;
opacity: 0;
translate: 0 20px;
transition:
visibility 0.3s,
opacity 0.3s ease,
translate 0.3s ease;
}
```
> **Important:** Sticky or floating elements hover above the scrollable content. Ensure that the main content has sufficient bottom padding or margin so that the last few elements are not permanently covered by the button when the user scrolls completely to the bottom.
### 3. Respond to the Scroll State
Use the `@container` rule with the `scroll-state` function. To check if the user has scrolled down, check if the container is scrollable to the top.
```css
/* When the container can be scrolled toward the top, it means the user has scrolled down */
@container scroll-state(scrollable: top) {
.back-to-top {
visibility: visible;
opacity: 1;
translate: 0 0;
}
}
```
## Fallback strategies
Container scroll-state queries has limited availability.
Supported by: Chrome 133 (Feb 2025) and Edge 133 (Feb 2025).
Unsupported in: Firefox and Safari.
### Basic Fallback
If `container-scroll-state-queries` is not supported, the floating element will remain invisible because of the default `visibility: hidden`. To ensure functionality, you can choose to make the element always visible in unsupported browsers.
```css
/* Fallback for browsers that do not support the feature */
.back-to-top {
visibility: visible; /* Always visible */
opacity: 1;
}
/* Override for supported browsers to handle dynamic visibility */
@supports (container-type: scroll-state) {
.back-to-top {
visibility: hidden;
opacity: 0;
}
@container scroll-state(scrollable: top) {
.back-to-top {
visibility: visible;
opacity: 1;
translate: 0 0;
}
}
}
```
### Advanced Fallback (Intersection Observer)
If dynamic visibility is required, use an `IntersectionObserver` to toggle a class when a sentinel element at the top of the scroller goes out of view.
```html
<!-- Sentinel element placed at the top of the scroller -->
<div class="scroll-sentinel"></div>
```
```css
/* Marker styling to ensure it does not affect layout */
.scroll-sentinel {
height: 0;
width: 0;
visibility: hidden;
}
.scrolled .back-to-top {
visibility: visible;
opacity: 1;
translate: 0 0;
}
```
```javascript
if (!CSS.supports('container-type', 'scroll-state')) {
const sentinel = document.querySelector('.scroll-sentinel');
const scroller = document.querySelector('.scroller');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// If the sentinel is NOT intersecting, it means the user has scrolled down
if (!entry.isIntersecting) {
scroller.classList.add('scrolled');
} else {
scroller.classList.remove('scrolled');
}
});
}, { root: scroller });
observer.observe(sentinel);
}
```
guides/ui-atoms/scroll-progress-indicator.md
# Build a Scroll Progress Indicator
A scroll progress indicator is a common user interface pattern that visually communicates the user's progress through a scrollable document or container. As the user scrolls, a visual element updates to reflect their position, providing a clear and intuitive sense of how much content has been viewed and how much remains.
## How to implement
To create a scroll progress indicator, you need two things:
1. An element to act as the progress bar. This element is typically `position: fixed` or `position: absolute` so that it stays in view while the user scrolls.
2. An animation that is linked to the scroll position.
Here’s how you can achieve this:
- First, create an HTML element that will serve as your progress bar. This element can be styled to your liking.
- Next, in your CSS, define a `@keyframes` animation that scales the progress bar. A common approach is to scale the element from `scaleX(0)` to `scaleX(1)`.
- Finally, apply this animation to your progress bar element and set its `animation-timeline` to a scroll-timeline. This tells the browser to drive the animation's progress based on the scroll position of the nearest ancestor scroller.
## Example code
This code grows the `#progress` element on scroll using an anonymous scroll-timeline, created by the `scroll()` function.
```css
@media (prefers-reduced-motion: no-preference) {
@supports ((animation-timeline: scroll())) {
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
#progress {
position: fixed;
left: 0; top: 0;
width: 100%; height: 1em;
background: red;
transform-origin: 0 50%;
animation: grow-progress auto linear;
animation-timeline: scroll();
}
}
}
```
Because of its location in the DOM, the `scroll()` function will track its nearest ancestor scroller in the `block` direction, which here is the root scroller.
```html
<body>
<!-- MANDATORY: Purely decorative visual scroll progress bars MUST set aria-hidden="true" to remove the empty element from the assistive technology reading tree -->
<div id="progress" aria-hidden="true"></div>
</body>
```
This code grows the `#progress` element on scroll using a named scroll-timeline, created by the `scroll-timeline` property.
```css
@media (prefers-reduced-motion: no-preference) {
@supports ((animation-timeline: scroll())) {
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
:root {
scroll-timeline: --tl block;
}
#progress {
position: fixed;
left: 0; top: 0;
width: 100%; height: 1em;
background: red;
transform-origin: 0 50%;
animation: grow-progress auto linear;
animation-timeline: --tl;
}
}
}
```
## Best Practices
When using scroll-driven animations, it's important to follow a few best practices to ensure a smooth and accessible experience:
- **DO** include feature detection: Not all browsers support scroll-driven animations. Use `@supports (animation-timeline: scroll())` to check for support and provide a fallback for browsers that don't support it.
- **DO NOT** use the `scroll-timeline-polyfill` package for the fallback strategy as it is not feature complete and has a lot of known issues.
- If the animation is only considered to be decorative, opt for Progressive Enhancement and **DO NOT** provide a fallback.
- **DO** remove purely decorative elements from assistive technology reading flows: Apply `aria-hidden="true"` to purely visual scroll indicators to ensure screen readers do not encounter empty, unnamed nodes.
- **DO** respect user preferences: Some users prefer to have less motion on the web. Use the `prefers-reduced-motion` media query to disable or reduce your animations for these users.
- **DO** try to animate only performant CSS properties: For the smoothest animations, stick to animating properties that can be handled by the browser's compositor thread, such as `transform` and `opacity`. Animating other properties like `width` or `height` can lead to performance issues.
- **DO** use the correct declaration order: When using the `animation` shorthand property, declare `animation-timeline` *after* it to prevent the shorthand from resetting the timeline.
When using the `scroll()` function to create a scroll-driven animation:
- **OPTIONAL** be explicit about the scroller: When not targeting the nearest ancestor scroller, be explicit about which scroller you want to use with `scroll(root)` or `scroll(self)`.
- When `root`, `nearest`, or `self` are not sufficient, use a named scroll-timeline.
- **OPTIONAL** be explicit about the axis to track: When not targeting the default `block` axis (such as in a horizontal scroller), be explicit about which axis to track with `scroll(block)` or `scroll(inline)`.
When using the `scroll-timeline` property to create a scroll-driven animation:
- **DO** use a CSS `<dashed-ident>` for the name.
- **OPTIONAL** be explicit about the axis to track: When not targeting the default `block` axis (such as in a horizontal scroller), be explicit about which axis to track with `scroll-timeline-axis`.
- **DO** make sure the scope of the lookup works: When the element that is declaring the `scroll-timeline` is not a flat tree ancestor of the animated element, hoist up the visibility of the `scroll-timeline`’s name by using `timeline-scope` on a shared ancestor.
## Fallback strategies
Scroll-driven animations has limited availability.
Supported by: Chrome 115 (Jul 2023), Edge 115 (Jul 2023), and Safari 26 (Sep 2025).
Unsupported in: Firefox.
For browsers that do not support scroll-driven animations, you can use a fallback to recreate the visual effects. The fallbacks are typically built with either a scroll listener (for ScrollTimeline effects) or the IntersectionObserver API (for ViewTimeline effects).
In browsers with built-in support for scroll-driven animations, ALWAYS use the native CSS implementation as those are more performant.
Note that not every effect can be recreated using the fallbacks approach.
For this use-case specifically, the following script applies the fallback for browsers that do not support scroll-driven animations. It uses a scroll listener to track the scroll position of the root element and updates the `transform` property of the progress bar accordingly.
```html
<script>
if (!CSS.supports('animation-timeline', 'scroll()')) {
const progress = document.querySelector('#progress');
window.addEventListener('scroll', () => {
const scrollable = document.documentElement.scrollHeight - window.innerHeight;
const scrolled = window.scrollY;
const progressPercentage = (scrolled / scrollable);
progress.style.transform = `scaleX(${progressPercentage})`;
});
}
</script>
```
guides/ui-atoms/scrollability-affordance-hints.md
# Scrollability Affordance Hints
## Overview
Visual hints, like shadows or gradients, help users understand that they can scroll to see more content. This guide shows how to build these hints using CSS `container-scroll-state-queries`, which allows styling elements based on the scrollable state of their container without relying on JavaScript scroll listeners or observers.
## Implementation
### 1. Establish the Scroll Container
The scroll container must be declared as a scroll-state query container.
```css
.scroller {
overflow-y: auto;
/* Establish this element as a scroll-state query container */
container-type: scroll-state;
position: relative;
}
```
### 2. Style the Indicators
Place the indicator elements (like shadows, gradients, or arrows) inside the container and style them. By default, they should not be visible. When they are shown, they should not be interactive, by setting `pointer-events: none`.
```css
.indicator-top, .indicator-bottom {
position: sticky;
left: 0;
right: 0;
height: 20px;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none; /* Let clicks pass through */
}
.indicator-top {
top: 0;
background: linear-gradient(to bottom, rgba(0,0,0,0.2), transparent); /* Example: Shadow */
}
.indicator-bottom {
bottom: 0;
background: linear-gradient(to top, rgba(0,0,0,0.2), transparent); /* Example: Shadow */
}
```
### 3. Query the Scroll State
Use the `@container` rule with the `scroll-state` function. Check if the container is scrollable up or down to show the respective indicator.
```css
/* Show top indicator when the user can scroll up */
@container scroll-state(scrollable: top) {
.indicator-top {
opacity: 1;
}
}
/* Show bottom indicator when the user can scroll down */
@container scroll-state(scrollable: bottom) {
.indicator-bottom {
opacity: 1;
}
}
```
## Fallback strategies
Container scroll-state queries has limited availability.
Supported by: Chrome 133 (Feb 2025) and Edge 133 (Feb 2025).
Unsupported in: Firefox and Safari.
### Basic Fallback
If the feature is not supported, the indicators will remain invisible. Since these are hints and not critical for functionality, it is acceptable to omit them in unsupported browsers.
### Advanced Fallback (Intersection Observer)
If the hints are required, use an `IntersectionObserver` to toggle classes when sentinel elements at the top and bottom of the scroller move in and out of the scrollport.
```html
<!-- Sentinel elements placed at the ends of the scroller -->
<div class="sentinel-top"></div>
<!-- Content goes here -->
<div class="sentinel-bottom"></div>
```
```css
/* Marker styling to ensure it does not affect layout */
.sentinel-top, .sentinel-bottom {
height: 0;
width: 0;
visibility: hidden;
}
.scroller.scrolled-down .indicator-top {
opacity: 1;
}
.scroller.can-scroll-down .indicator-bottom {
opacity: 1;
}
```
```javascript
if (!CSS.supports('container-type', 'scroll-state')) {
const topSentinel = document.querySelector('.sentinel-top');
const bottomSentinel = document.querySelector('.sentinel-bottom');
const scroller = document.querySelector('.scroller');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.target === topSentinel) {
// If top sentinel is not intersecting, we have scrolled down
scroller.classList.toggle('scrolled-down', !entry.isIntersecting);
}
if (entry.target === bottomSentinel) {
// If bottom sentinel is intersecting, we reached the bottom
scroller.classList.toggle('can-scroll-down', !entry.isIntersecting);
}
});
}, { root: scroller });
observer.observe(topSentinel);
observer.observe(bottomSentinel);
}
```
guides/ui-atoms/shrinking-header-on-scroll.md
# Shrinking header on scroll
A shrinking header on scroll is a common UI pattern where a fixed header element at the top of the page smoothly transitions to a smaller size as the user scrolls down. This effect is often used to maximize screen real estate for the main content while keeping essential navigation or branding elements accessible. With CSS scroll-driven animations, this effect can be achieved in a declarative and performant way, by linking an animation to the scroll position of the document.
## How to implement
Here’s how to create a shrinking header on scroll:
1. **Create a fixed header:** Start with a header element that is fixed to the top of the page and has a predefined height.
```html
<header>HEADER</header>
```
```css
header {
position: fixed;
height: 200px;
top: 0;
left: 0;
right: 0;
}
```
2. **Define the shrink animation:** Create a CSS animation that changes the height of the header.
```css
@keyframes shrink {
to {
height: 50px;
}
}
```
3. **Apply the animation and scroll timeline:** Attach the animation to the header and use the `scroll()` function to link it to the document’s scroll position.
```css
header {
animation: shrink auto linear both;
animation-timeline: scroll(block root);
}
```
4. **Set the `animation-range`:** Use the `animation-range` property to specify the scroll distance over which the animation should occur. For example, to shrink the header over the first 150 pixels of scrolling, you would use `animation-range: 0px 150px;`.
```css
header {
animation-range: 0px 150px;
}
```
**Tip:** To prevent the content following the header from being obscured by it, add a `padding-top` to the `body` (or the main content container) that is equal to the initial height of the header.
**Tip:** To make sure the contents of the page scroll in sync with the shrinking header, set the `animation-range-end` to the difference between the start and end sizes. This ensures the animation completes precisely when the header reaches its final size. In this demo the header shrinks from `200px` to `50px`, so the `animation-range-end` is set to `150px`.
## Example code
```css
@keyframes shrink {
to {
height: 50px;
}
}
header {
animation: shrink auto linear both;
animation-timeline: scroll(block root);
animation-range: 0px 150px;
}
```
## Best Practices
When using scroll-driven animations, it's important to follow a few best practices to ensure a smooth and accessible experience:
- **DO** include feature detection: Not all browsers support scroll-driven animations. Use `@supports ((animation-timeline: scroll()) and (animation-range: 0% 100%))` to check for support and provide a fallback for browsers that don't support it.
- The `(animation-range: 0% 100%)` check **MUST** be included here, to filter out browsers with only partial support.
- **DO NOT** use the `scroll-timeline-polyfill` package for the fallback strategy as it is not feature complete and has a lot of known issues.
- If the animation is only considered to be decorative, opt for Progressive Enhancement and **DO NOT** provide a fallback.
- **DO** respect user preferences: Some users prefer to have less motion on the web. Use the `prefers-reduced-motion` media query to disable or reduce your animations for these users.
- **DO** try to animate only performant CSS properties: For the smoothest animations, stick to animating properties that can be handled by the browser's compositor thread, such as `transform` and `opacity`. Animating other properties like `width` or `height` can lead to performance issues.
- **DO** use the correct declaration order: When using the `animation` shorthand property, declare `animation-timeline` and `animation-range` *after* it to prevent the shorthand from resetting the timeline.
When using the `scroll()` function to create a scroll-driven animation:
- **OPTIONAL** be explicit about the scroller: When not targeting the nearest ancestor scroller, be explicit about which scroller you want to use with `scroll(root)` or `scroll(self)`.
- When `root`, `nearest`, or `self` are not sufficient, use a named scroll-timeline.
- **OPTIONAL** be explicit about the axis to track: When not targeting the default `block` axis (such as in a horizontal scroller), be explicit about which axis to track with `scroll(block)` or `scroll(inline)`.
As for this use case specifically:
- The element that you animate **MUST** not be `position: static` or `position: relative` when using percentages in the `animation-range`.
- This is because those elements are considered “in-flow”. Shrinking those elements as you scroll, would shrink the total scroll distance, thereby affecting the computed value of — for example — `10%` into the scroll.
## Browser support and fallback strategies
Scroll-driven animations has limited availability.
Supported by: Chrome 115 (Jul 2023), Edge 115 (Jul 2023), and Safari 26 (Sep 2025).
Unsupported in: Firefox.. Therefore, a fallback strategy is typically required.
For browsers that do not support scroll-driven animations, you can use a fallback to recreate the visual effects. The fallbacks are typically built with either a scroll listener (for ScrollTimeline effects) or the IntersectionObserver API (for ViewTimeline effects).
In browsers with built-in support for scroll-driven animations, ALWAYS use the native CSS implementation as those are more performant.
Note that not every effect can be recreated using the fallbacks approach.
For this use-case specifically, the following script applies the fallback for browsers that do not support scroll-driven animations. It uses a scroll listener to track the scroll position of the document over a distance of `150px` and updates the header's height accordingly.
```js
// Fallback for browsers that don't support scroll-driven animations
if (!CSS.supports('(animation-timeline: scroll()) and (animation-range: 0% 100%)')) {
const header = document.querySelector('header');
const initialHeight = 200;
const finalHeight = 50;
const scrollDistance = 150;
window.addEventListener('scroll', () => {
const scrollY = window.scrollY;
const scrollPercent = Math.min(1, scrollY / scrollDistance);
const newHeight = initialHeight - (initialHeight - finalHeight) * scrollPercent;
header.style.height = `${newHeight}px`;
});
}
```
guides/ui-atoms/state-aware-sticky-headers.md
# State-Aware Sticky Headers
Sticky headers are a common UI pattern, but they often need to change their appearance when they become "stuck" to maintain readability or save space. Traditional solutions required JavaScript scroll listeners, which can cause performance issues. Modern CSS allows you to handle this natively using **Scroll State Queries**.
## Implementation Steps
### 1. Create the Sticky Container
You need a container that will act as the sticky element and the container for the scroll state query.
```html
<!-- Wrap in a section to define the containment area for the sticky element -->
<div class="section">
<div class="sticky-container">
<div class="sticky-header">
Section Header
</div>
</div>
<div class="content">
<!-- Content goes here -->
</div>
</div>
```
### 2. Apply CSS for Sticky Behavior and Container Type
Set `container-type: scroll-state` on the `position: sticky` element, not on its scrollable ancestor. Always specify a `container-name`, since these queries may be nested and unnamed containers will collide. You can also combine it with size queries, e.g., `container-type: scroll-state inline-size`.
```css
.sticky-container {
position: sticky;
top: 0;
/* MANDATORY: Always specify a container name to avoid collisions if CQs are nested */
container-type: scroll-state;
container-name: section-header;
z-index: 10;
}
.sticky-header {
/* Base styles for the header */
background: #f0f0f0;
padding: 20px;
transition: background-color 0.3s, box-shadow 0.3s;
}
```
**Important:** The scroll state is queried by **descendants** of the scroll-state container. The styles in the `@container` query will not apply to the `.sticky-container` itself, but to its children (like `.sticky-header`). You cannot style the container element itself with its own scroll-state query.
### 3. Target the Stuck State
Use the `@container scroll-state(...)` query to apply styles when the header is stuck.
```css
/* Apply styles when the named container is stuck at the top */
@container section-header scroll-state(stuck: top) {
.sticky-header {
background: #0056b3;
color: white;
box-shadow: 0 4px 6px rgb(0 0 0 / 0.15);
}
}
```
**Tip:** You can also use logical properties like `stuck: inset-block-start` or `stuck: inset-inline-start` to better support internationalization (i18n) and right-to-left (RTL) layouts by querying flow-relative edges rather than physical ones.
### Notes on Dimension Changes
Without scroll anchoring disabled, changing layout-affecting properties (height, padding, font-size) when stuck can cause visual flickering. Scroll anchoring on the in-flow content below the sticky element adjusts the scroll offset to compensate for the layout change, which pushes the element back out of its stuck position and triggers an oscillation.
Disable scroll anchoring on the parent of the sticky element to avoid this:
```css
.section {
overflow-anchor: none;
}
```
Apply `overflow-anchor: none` to the direct parent of the `position: sticky` element, not to `:root` or `body`, which would disable scroll anchoring for the entire page. With this in place, you can freely change any property in the stuck state, including box-model properties and transforms.
DO NOT: Rely on `overflow-anchor: none` for elements stuck to the **bottom**. Scroll anchoring only compensates for layout shifts above the current scroll position, so it has no effect on bottom-stuck elements. Avoid changing box-model properties in the stuck state for bottom-stuck headers.
### Fallback strategies
Container scroll-state queries has limited availability.
Supported by: Chrome 133 (Feb 2025) and Edge 133 (Feb 2025).
Unsupported in: Firefox and Safari.
Scroll state queries are a progressive enhancement. In browsers that do not support `container-type: scroll-state`, the header will still stick to the top (due to `position: sticky`), but it will not visually transform. For most use cases, this is the recommended approach.
If stickiness is not essential, but `position: sticky` without different styling would be a worse experience than no stickiness at all, you can wrap the `position: sticky` and related declaration in an `@supports` query as well:
```css
.sticky-container {
@supports (container-type: scroll-state) {
position: sticky;
top: 0;
container-type: scroll-state;
container-name: section-header;
z-index: 10;
}
}
```
**Tip:** If your "stuck" styling requires a different background color for readability, consider setting your layout up so that the default styling works everywhere (Progressive Enhancement). If you must use a fallback, you can gate the `position: sticky` behaviour itself inside an `@supports (container-type: scroll-state)` query.
If the visual transformation is absolutely critical to the design (e.g., the stuck state introduces a background or compaction without which the content is unreadable), you can implement a robust JavaScript fallback using `IntersectionObserver`. You must duplicate your CSS styles under an `.is-stuck` class. The following generic polyfill checks `getComputedStyle` to traverse up and find the correct scroll container (defaulting to the viewport), matching the behavior of `@container scroll-state(stuck: top)`:
```javascript
function getScrollParent(node) {
if (node == null || node === document.body || node === document.documentElement) {
return null; // default to viewport
}
if (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth) {
const overflow = getComputedStyle(node).overflow;
if (overflow !== 'visible' && overflow !== 'clip') {
return node;
}
}
return getScrollParent(node.parentNode);
}
document.querySelectorAll('.sticky-container').forEach(container => {
const root = getScrollParent(container);
const topOffset = parseFloat(getComputedStyle(container).top) || 0;
const observer = new IntersectionObserver(
([e]) => {
// Toggle the fallback class on the sticky header container
e.target.classList.toggle('is-stuck', e.intersectionRatio < 1);
},
{
root: root,
threshold: [1],
rootMargin: `-${topOffset + 1}px 0px 0px 0px`
}
);
observer.observe(container);
});
```
*Note: This generic IntersectionObserver pattern can also be used as a polyfill for the `scroll-state(scrollable)` query.*
guides/ui-behaviors/anchor-positioning-tab-underline.md
# Anchor Positioning Tab Underline
In a tab menu, you should provide visual hints to users about what page they are on. One option is by underlining the tab. With anchor positioning, you can create a smooth animation between the positions of the underline. This does not work when changing the active tab loads a new web page.
You can also use this effect to add an animated dot to indicate the active tab in a vertical tab bar.
Create the underline using a `::before` pseudo-element on the `<ul>` that contains the `<li>` elements. **Using a pseudo-element is the preferred approach as it keeps the DOM clean and avoids adding extra elements for purely decorative effects.**
```css
ul::before {
/* Use a pseudo-element on the container to represent the animated indicator */
content: '';
}
```
Make the active list item an anchor by adding the `anchor-name` property, which has a value that starts with `--`.
```css
li.active {
/* Make a unique anchor-name for the active element. */
anchor-name: --active;
}
```
Tether the underline to the active item anchor with a `position-anchor` that matches the `anchor-name` on the anchor, and making it `position: absolute`.
```css
ul::before {
/* Tether the underline to the active element. */
position: absolute;
position-anchor: --active;
}
```
Position the underline relative to the anchor using the inset properties and `anchor()` functions.
```css
ul::before {
/* DO NOT use position-area, which can not be transitioned. */
/* Use calc() to offset the top slightly */
inset-block-start: calc(anchor(bottom) + .1lh);
inset-inline-start: anchor(left);
inset-inline-end: anchor(right);
}
```
Add a height and other visual styles.
```css
ul::before {
/* Apply your project's styles for the indicator */
block-size: .25lh;
background: red;
}
```
Finally, add a transition on the `inset` properties.
```css
ul::before {
@media (prefers-reduced-motion: no-preference) {
/* MANDATORY: The transition must be wrapped in a prefers-reduced-motion media query to respect user preferences. */
transition: inset .2s;
}
}
```
This is only a visual indicator, and must not be a replacement for setting the appropriate `aria-current="page"` or `aria-selected` aria values.
```html
<!-- MANDATORY: Provide explicit assistive technology state alongside the visual tab underline -->
<nav aria-label="Primary">
<ul>
<li class="active">
<a href="/home" aria-current="page">Home</a>
</li>
<li>
<a href="/about">About</a>
</li>
</ul>
</nav>
```
## Fallback strategies
Anchor positioning is not natively supported by any major browser yet.
If anchor positioning is not supported in the browser, use a `border-bottom` to add an underline. It will not be animated.
```css
ul li.active {
@supports not (position-anchor: auto) {
/* Choose a color appropriate to the app theme. */
border-bottom: .25lh var(--primary) solid;
}
}
```guides/ui-behaviors/animate-element-entry-exit.md
# Animate Element Entry and Exit
In the past, CSS transitions could not animate elements when they were first added to the DOM or when their `display` property changed from `none`. The `@starting-style` at-rule and `transition-behavior: allow-discrete` provide a declarative way to create smooth entry and exit animations.
## Implementation
### 1. Animating `display: none` Toggles
To animate an element when toggling its visibility via an attribute (e.g., `hidden` with `display: none`):
1. **Define the visible state**: Set the final property values (e.g., `opacity: 1`) on the base class.
2. **Define the entry starting state**: Use `@starting-style` to specify the values to transition *from* when the element becomes visible.
3. **Enable discrete transitions**: Include `display` in the `transition` property and use `transition-behavior: allow-discrete`.
4. **Define the exit state**: Set the target values in the `hidden` attribute.
```css
.card {
display: block;
opacity: 1;
translate: 0;
/* MANDATORY: Use transition-behavior: allow-discrete for display transition */
transition:
display 0.4s,
opacity 0.4s ease-out,
translate 0.4s ease-out;
transition-behavior: allow-discrete;
}
/* Entry animation: transition FROM these values when first rendered */
@starting-style {
.card {
opacity: 0;
translate: 0 -20px;
}
}
/* Exit animation: transition TO these values when hidden */
.card:where(.hidden, [hidden]) {
display: none;
opacity: 0;
translate: 0 -20px;
}
/* Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
.card {
/* Disable movement and shorten duration for a simple fade */
translate: none;
transition-duration: 0.1s;
}
@starting-style {
.card {
translate: none;
}
}
.card:where(.hidden, [hidden]) {
translate: none;
}
}
```
### 2. Animating DOM Insertion and Removal
For elements added via `appendChild()` or removed via `remove()`:
- **Entry**: Use `@starting-style` as shown above. The browser will automatically detect the style change from "nothing" to the element's initial styles and trigger the transition from the `@starting-style` values.
- **Removal**: Since `element.remove()` is instantaneous and doesn't trigger a CSS transition on its own, you must trigger the exit transition first (e.g., by adding a class) and wait for it to finish before removing the node from the DOM.
```javascript
// Trigger exit transition
element.setAttribute('hidden', true);
// 2. Wait for all active transitions/animations to finish,
// with a failsafe timeout in case an animation never ends (e.g. for looping animations)
const animations = element.getAnimations();
if (animations.length > 0) {
await Promise.race([
// Promise.allSettled ensures we wait even if some animations fail
Promise.allSettled(animations.map(a => a.finished)),
new Promise(r => setTimeout(r, 2000))
]);
}
// 3. Finally remove the node from the DOM
element.remove();
```
## Constraints & Accessibility
- **MANDATORY**: Use `transition-behavior: allow-discrete` when transitioning `display`. Without it, the element will instantly disappear during exit.
- **DO NOT** use `allow-discrete` in the `transition` shorthand — it will make older browsers ignore the entire `transition` declaration. Except in use cases where that is desirable, use a separate `transition-behavior: allow-discrete` declaration.
- **MANDATORY**: Use `@starting-style` for entry animations. Browsers skip transitions on an element's first style update (initial render or `display: none` change) unless this is provided.
- **DO**: Include `overlay` in the `transition` list if animating top-layer elements like `<dialog>` or `popover` to ensure they stay in the top layer during the exit animation.
- **DO**: Respect user preferences for reduced motion using the `prefers-reduced-motion` media query.
- **DO NOT**: Rely on `@starting-style` for exit animations; it only defines the *starting* point for an entry transition. Exit animations are defined by the transition to the hidden state.
## Fallback strategies
Baseline status for @starting-style: Newly available. It's been Baseline since 2024-08-06.
Supported by: Chrome 117 (Sep 2023), Edge 117 (Sep 2023), Firefox 129 (Aug 2024), and Safari 17.5 (May 2024).
For browsers that do not support these features, elements will toggle `display: none` instantly. You can detect support in JavaScript using `CSS.supports()` to conditionally apply manual animation logic.
```javascript
// Detect support for discrete transitions and starting-style
const supportsModernTransitions =
window.CSS &&
CSS.supports('transition-behavior', 'allow-discrete');
if (!supportsModernTransitions) {
// Implement manual JS-based fallback for entry/exit
}
```
### Manual Entry Animation (JS Fallback)
```javascript
// To show:
el.style.display = '';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.classList.remove('hidden');
});
});
// To hide:
el.setAttribute('hidden', true);
el.addEventListener('transitionend', () => {
if (el.classList.contains('hidden')) el.style.display = 'none';
}, { once: true });
```
guides/ui-behaviors/animate-to-from-top-layer.md
# Animate Elements To and From Top Layer
Elements that render in the "top layer" (like `<dialog>`, elements with the `popover` attribute, or tooltips) have historically been difficult to animate because they toggle between `display: none` and a visible state. Modern CSS provides `@starting-style`, `transition-behavior: allow-discrete`, and the `overlay` property to enable smooth entry and exit transitions for these elements. Note that native CSS nesting is used in the examples below.
## Implementation
### 1. Enable Discrete Transitions
To animate the `display` property, you must set `transition-behavior: allow-discrete`. This allows the element to remain visible during its exit transition. If using transition shorthands, be sure to place the `transition-behavior: allow-discrete` afterwards to prevent the shorthand from negating it.
### 2. The `overlay` Property
When an element moves in or out of the top layer, it must transition the `overlay` property. This ensures the element stays in the top layer for the duration of the animation, preventing it from being clipped by other elements or the viewport prematurely.
### 3. Entry Animations with `@starting-style`
Use the `@starting-style` at-rule to define the styles an element should transition *from* when it is first rendered or its `display` changes from `none`.
### 4. Animating the Backdrop
The `::backdrop` pseudo-element can be animated similarly by applying transitions to its own properties.
## Example
```css
/* 1. Define the visible (open) state */
dialog[open],
[popover]:popover-open {
opacity: 1;
transform: scale(1);
/* 2. Define the starting state for entry (must come after open state) */
@starting-style {
opacity: 0;
transform: scale(0.9);
}
}
/* 3. Define the base (closed/exit) state and transitions */
dialog,
[popover] {
opacity: 0;
transform: scale(0.9);
/* MANDATORY: transition display and overlay for top-layer elements */
transition-property: opacity, transform, display, overlay;
transition-duration: 0.3s;
transition-timing-function: ease-out;
/* Applies to discrete properties like display and overlay */
transition-behavior: allow-discrete; /* Note: be sure to write this after the shorthand */
}
/* 4. Animate the backdrop */
dialog::backdrop,
[popover]::backdrop {
background-color: rgba(0, 0, 0, 0);
/* The transition shorthand can also be used with allow-discrete */
transition:
display 0.3s allow-discrete,
overlay 0.3s allow-discrete,
background-color 0.3s ease-out;
}
dialog[open]::backdrop,
[popover]:popover-open::backdrop {
background-color: rgba(0, 0, 0, 0.5);
@starting-style {
background-color: rgba(0, 0, 0, 0);
}
}
/* 5. Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
dialog,
[popover] {
/* Disable movement and shorten duration for a simple fade */
transform: none;
transition-duration: 0.1s;
}
@starting-style {
dialog[open],
[popover]:popover-open {
transform: none;
}
}
}
```
## Constraints & Accessibility
- **MANDATORY**: Include `overlay` in your `transition` list for any element moving into or out of the top layer.
- **MANDATORY**: Use `allow-discrete` for the `display` property transition.
- **MANDATORY**: Respect user preferences for reduced motion using `prefers-reduced-motion` by simplifying transitions (e.g., removing transforms and shortening duration).
- **DO**: Place the `@starting-style` block inside or after the "open" state selector to ensure proper cascading.
- **DO NOT**: Use `@starting-style` for exit animations; exit animations are defined by the transition to the base (closed) state.
## Fallback strategies
### Top-layer animation features
Baseline status for @starting-style: Newly available. It's been Baseline since 2024-08-06.
Supported by: Chrome 117 (Sep 2023), Edge 117 (Sep 2023), Firefox 129 (Aug 2024), and Safari 17.5 (May 2024).
Baseline status for transition-behavior: Newly available. It's been Baseline since 2024-08-06.
Supported by: Chrome 117 (Sep 2023), Edge 117 (Sep 2023), Firefox 129 (Aug 2024), and Safari 17.4 (Mar 2024).
overlay has limited availability.
Supported by: Chrome 117 (Sep 2023) and Edge 117 (Sep 2023).
Unsupported in: Firefox and Safari.
For browsers that do not support these features, top-layer elements will appear and disappear instantly. To provide animations in older browsers, you must use JavaScript to coordinate classes and wait for `transitionend` events or use the Web Animations API.
```javascript
// Feature detection for top-layer animations
const supportsTopLayerAnimation =
window.CSS &&
CSS.supports('transition-behavior', 'allow-discrete') &&
CSS.supports('overlay', 'auto');
if (!supportsTopLayerAnimation) {
// Manual JS fallback for entry/exit animations:
// 1. Add an `.is-opening` class for entry.
// 2. On close, add an `.is-closing` class, wait for the `transitionend` event, then call .close() or hide the popover.
}
```
### Fallbacks & browser support for Popover
Baseline status for Popover: Newly available. It's been Baseline since 2025-01-27.
Supported by: Chrome 116 (Aug 2023), Edge 116 (Aug 2023), Firefox 125 (Apr 2024), Safari 17 (Sep 2023), and Safari iOS 18.3 (Jan 2025).
The Popover API is mostly **progressive enhancement**, but its defining behaviors — top-layer promotion, light-dismiss, and `popovertarget` invocation — have no CSS-only equivalent. Older browsers need a polyfill, or a manual fallback if you would rather not ship one.
**Polyfill:** To support the `popover` attribute in older browsers, conditionally load [`@oddbird/popover-polyfill`](https://github.com/oddbird/popover-polyfill). **MANDATORY:** Feature detect by checking for the `popover` property on `HTMLElement.prototype`, and load the polyfill **only** when native support is missing — do NOT load it unconditionally.
With a bundler or import map:
```js
// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
import("@oddbird/popover-polyfill");
}
```
Without a bundler, import from a CDN inside a `<script type="module">`:
```html
<script type="module">
if (!("popover" in HTMLElement.prototype)) {
import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
}
</script>
```
**Styling caveat:** The polyfill cannot define the real `:popover-open` pseudo-class, so it applies a `.\:popover-open` class instead. **MANDATORY:** Combine the two with `:is()` or `:where()`, otherwise browsers that lack `:popover-open` discard the entire rule:
```css
[popover]:is(:popover-open, .\:popover-open) {
display: block;
}
```
Alternatively, for a legacy fallback without a polyfill, use `position: fixed` and manually calculate coordinates via `getBoundingClientRect()` or rely on default positioning with `inset: auto` if that's acceptable for the use case.
guides/ui-behaviors/carousel-snap-highlights.md
# Carousel Snap Highlights
Scroll-state container queries allow you to style elements based on their current scroll state, such as whether an element is "stuck" (via sticky positioning) or "snapped" (via scroll snapping). This enables carousel or gallery experiences where the active item can be visually distinguished without relying on JavaScript intersection observers or scroll event listeners.
### Core implementation
To highlight snapped items, you must establish a scroll-snap container, define the snap targets as scroll-state containers, and then query that state to style descendants.
#### 1. Establish the scroll snap container
The parent container must have `scroll-snap-type` enabled.
```html
<div class="carousel">
<div class="carousel-item">
<div class="card">Product 1 content</div>
</div>
<div class="carousel-item">
<div class="card">Product 2 content</div>
</div>
</div>
```
```css
.carousel {
display: flex;
overflow-x: auto;
/* MANDATORY: Enable scroll snapping on the container */
scroll-snap-type: x mandatory;
}
```
#### 2. Define snap targets as scroll-state containers
Each item in the carousel that should be tracked for snapping must be declared as a `scroll-state` container.
```css
.carousel-item {
/* Define where the item snaps within the container */
scroll-snap-align: center;
/* MANDATORY: Establish this element as a scroll-state query container */
container-type: scroll-state;
}
```
#### 3. Query the `snapped` state
Because container queries style **descendants**, you must apply the highlight styles to an element *inside* the snap target. Because the scroll container is set to overflow on the x axis, use the `scroll-state(snapped: x)` query.
**MANDATORY**: Wrap the styles in ` @media (prefers-reduced-motion: no-preference)` to only show the effect to users who have not requested reduced motion. Depending on your use case, you may retain portions of the effect, but in this case, the cards flash from white to blue in a way that may cause problems for some users, so we disable it completely.
```css
/* Specify transition outside of queries so that it is applied regardless of state. */
.card {
transition:
scale 0.4s cubic-bezier(0.25, 0.8, 0.25, 1),
background-color 0.4s,
color 0.4s,
box-shadow 0.4s;
}
/*
Only show the effect for users not requesting reduced motion. Disable completely, including the color change, as it causes a flash that may be problematic.
*/
@media (prefers-reduced-motion: no-preference) {
/* Style the content when its parent .carousel-item is snapped on the x axis */
@container scroll-state(snapped: x) {
.card {
background: #007bff;
color: white;
scale: 1.15;
box-shadow: 0 10px 25px rgba(0, 123, 255, 0.3);
}
}
}
/* MANDATORY Copy-Paste Safety: Disable highlight scaling/flashing for motion sensitive users */
@media (prefers-reduced-motion: reduce) {
.card {
transition: none !important;
scale: 1 !important;
}
}
```
The `snapped` descriptor can query specific axes: `x`, `y`, `inline`, `block`, or `both`.
### Accessibility
**AVOID**: using `scroll-state` with interactive elements.
Visual highlights for snapped items can improve the UX, but the snapped item is not exposed to the accessibility tree. The visual theme applied to a snapped item should not convey that the element is active or focused, and a keyboard focus ring should be highly visible and distinct from the `snapped` highlight. If the snapped item is interactive, you must use other standard accessibility practices to make it accessible.
Snapping occurs due to scrolling, which does not move keyboard focus. However, keyboard focus may cause the scroll container to move, causing a change in the snapped item, which may or may not be the focused item. This will likely be a source of confusion for users and is discouraged.
> [!NOTE]
> Detailed accessibility requirements for carousels (such as ARIA roles, slide attributes, and complex keyboard patterns) have been intentionally omitted from this guide. Carousel accessibility is highly nuanced and context-dependent; refer to established accessibility standards and perform thorough user testing for production environments.
## Fallback strategies
Container scroll-state queries has limited availability.
Supported by: Chrome 133 (Feb 2025) and Edge 133 (Feb 2025).
Unsupported in: Firefox and Safari.
For browsers that do not support scroll-state queries, you should provide a functional base experience where all items are legible, even without the "active" highlight.
#### Feature detection
You can use `@supports` to provide enhancements only to supported browsers:
```css
@supports (container-type: scroll-state) {
/* Enhancement styles here */
}
```
#### JavaScript fallback
If the highlight is critical for the user experience, use `IntersectionObserver` to determine the snapped item. Adjust the observed area to a thin slice in the center of the carousel by providing a `rootMargin` with a negative inline value. For example, to consider an element to be intersecting if it is in the center 2% of the carousel, set the `rootMargin` to `"0px -49%"`.
```javascript
// Optional: detect support and apply a JS-based fallback
if (!CSS.supports('container-type', 'scroll-state')) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// Toggle a class based on intersection
entry.target.classList.toggle('is-snapped', entry.isIntersecting);
});
}, {
root: document.querySelector(".carousel"),
// Carousel item intersects if any part of the carousel item is in the middle 2% of the carousel.
rootMargin: "0px -49%"
});
document.querySelectorAll('.carousel-item').forEach(item => {
observer.observe(item);
});
}
```
guides/ui-behaviors/consistent-cross-document-transitions.md
# Consistent Cross-Document Transitions
## The Problem
Cross-document view transitions animate elements between two pages during a same-origin navigation. The browser captures a snapshot of the old page, navigates, then animates from the snapshot to the new page. If the new page has not finished loading critical resources — stylesheets, layout scripts, or key DOM elements — the transition animates to an incomplete or unstyled state. This causes visual glitches such as elements morphing to wrong positions, content reflowing mid-animation, or fallback fonts flashing to web fonts after the transition completes.
## The Solution
Use `blocking="render"` on critical `<link>` and `<script>` elements in the new page's `<head>`, and use `<link rel="expect">` to block rendering until specific DOM elements have been parsed. This ensures the browser does not begin the view transition animation until the new page's visual state is stable. The browser continues parsing the HTML in the background — only painting is deferred.
### Implementation Strategy
1. **MANDATORY:** Opt in to cross-document view transitions with the `@view-transition` CSS at-rule on both pages.
2. **MANDATORY:** Ensure critical stylesheets are in the `<head>`. Stylesheets in the `<head>` are render-blocking by default. Dynamically injected stylesheets require explicit `blocking="render"`.
3. **DO** use `blocking="render"` on `<script>` elements that must execute before the transition animates (e.g., scripts that apply a theme or affect the layout).
4. **DO** use `<link rel="expect" href="#element-id" blocking="render">` to block rendering until above-the-fold content has been parsed. This applies to all transition types: full-page cross-fades (to avoid animating to a blank page), morph animations (to ensure named elements exist in the DOM), and script-dependent layouts (to ensure styled content is parsed).
5. **DO NOT** block rendering on non-critical content. Only block on resources and elements that affect the initial viewport. Blocking on too much content delays the transition and degrades perceived performance.
## Implementation Guide
### Step 1: Opt in to Cross-Document View Transitions
MANDATORY: Both the source and destination pages must include the `@view-transition` at-rule. Without this, no cross-document transition occurs.
```css
/*
MANDATORY: Include this rule in every page that participates
in cross-document view transitions.
`navigation: auto` enables transitions for standard navigations
(link clicks, form submissions, back/forward).
*/
@view-transition {
navigation: auto;
}
/* MANDATORY Copy-Paste Safety: Disable cross-document view transitions for users requesting reduced motion */
@media (prefers-reduced-motion: reduce) {
@view-transition {
navigation: none;
}
}
```
### Step 2: Block Rendering Until Critical Scripts Execute
If a non-blocking script in the `<head>` must run before the transition animates (e.g., to apply a theme class or affect the layout), mark it with `blocking="render"`. Without this, `async`, `defer`, or `type="module"` scripts may execute after the transition has already started.
```html
<head>
<!--
DO: Mark layout-critical scripts with blocking="render".
-->
<script type=module blocking="render">
// Example: apply a stored theme before the page renders,
// so the transition snapshot reflects the correct theme.
document.documentElement.dataset.theme =
localStorage.getItem('theme') || 'light';
</script>
</head>
```
### Step 3: Block Rendering Until Key DOM Elements Are Parsed
Stylesheets and `blocking="render"` scripts in the `<head>` only guarantee that the `<head>` has been fully processed. They do **not** wait for any `<body>` content to be parsed. Without additional blocking, the browser may take the new-page snapshot before above-the-fold elements exist in the DOM — resulting in a transition that animates to a blank or partially rendered page.
`<link rel="expect">` solves this by blocking rendering until a specific element (identified by its `id`) has been parsed. The `href` value must be a fragment identifier (e.g., `#hero`) matching the target element's `id` attribute. Once that element's closing tag is parsed, the render block is released.
**DO** use `<link rel="expect">` in all of the following scenarios:
#### Use Case 1: Full-Page Cross-Fade
Even when no individual elements have a `view-transition-name`, the default `root` transition cross-fades the entire page. If the new page's snapshot is taken before above-the-fold content is parsed, the cross-fade animates from the old page to a blank or incomplete page. Block rendering on an element that marks the end of the visible above-the-fold content.
```html
<head>
<link rel="stylesheet" href="/css/styles.css">
<!--
DO: Block rendering until the main content area is parsed,
even for a simple cross-fade. Without this, the browser may
snapshot the page before visible content exists in the DOM,
causing the cross-fade to reveal a blank or partial page.
-->
<link rel="expect" href="#main-content" blocking="render">
</head>
<body>
<header>...</header>
<main id="main-content">
<h1>Page Title</h1>
<p>Above-the-fold content the user should see immediately.</p>
</main>
<!-- Content below the fold does NOT need to be blocked on -->
<section>...</section>
</body>
```
#### Use Case 2: Morph Animations Between Specific Elements
When elements on both pages share a `view-transition-name`, the browser morphs them smoothly across the navigation. If the target element has not been parsed when the transition starts, the browser cannot find it — the morph degrades to separate exit and entry animations. Block rendering until the element with the `view-transition-name` has been parsed.
```html
<head>
<link rel="stylesheet" href="/css/styles.css">
<!--
DO: Block rendering until the element participating in the
morph animation has been parsed. Without this, the browser
may start the transition before #hero exists, causing the
morph to degrade to a fade-out/fade-in.
-->
<link rel="expect" href="#hero" blocking="render">
<!--
When multiple blocking="render" resources are present,
rendering is blocked until ALL of them are satisfied.
Here, the browser waits for both the script to execute
AND the #hero element to be parsed — whichever comes last.
-->
<script async blocking="render" src="/js/transition-setup.js"></script>
</head>
<body>
<header>...</header>
<section id="hero">
<h1 style="view-transition-name: page-title">Product Name</h1>
<img style="view-transition-name: hero-image" src="/img/product.webp" alt="Product">
</section>
</body>
```
### Step 4: Use Media Queries for Responsive Render Blocking
Different viewport sizes may show different amounts of content above the fold. Use the `media` attribute on `<link rel="expect">` to block rendering only for the content visible at a given viewport width.
```html
<head>
<!--
DO: Use media queries to conditionally block rendering.
On wide screens, both the hero and the sidebar are visible,
so block until both are parsed. On narrow screens, only the
hero is visible initially.
-->
<link
rel="expect"
href="#hero"
blocking="render"
media="screen and (width <= 768px)"
>
<link
rel="expect"
href="#sidebar"
blocking="render"
media="screen and (width > 768px)"
>
</head>
```
### Step 5: Use pagereveal for Context-Dependent Transitions (Optional)
The `pagereveal` event is **not required** for the core render-blocking strategy. It is only needed when `view-transition-name` values must be assigned dynamically based on where the user navigated from — for example, morphing a specific list item to a detail page heading.
If `view-transition-name` values are assigned statically in CSS, or if you are only using the default full-page cross-fade, skip this step entirely.
```html
<head>
<!--
MANDATORY: The pagereveal listener must be registered before
the page renders. Use an async script with blocking="render"
so the listener is registered early without blocking parsing.
If the listener is registered too late (e.g., in a deferred
script), the event may have already fired.
-->
<script async blocking="render" src="/js/transition-setup.js"></script>
</head>
```
```javascript
// transition-setup.js
window.addEventListener('pagereveal', async (event) => {
if (!event.viewTransition) return;
const from = navigation.activation?.from;
if (!from) return;
const fromUrl = new URL(from.url);
// DO: Assign view-transition-name based on navigation context.
// This enables a morph animation from the product card on the
// list page to the heading on the detail page.
if (fromUrl.pathname === '/products/') {
const heading = document.querySelector('main h1');
if (heading) {
heading.style.viewTransitionName = 'product-title';
}
// MANDATORY: Remove the temporary name after the transition
// finishes. Stale names interfere with subsequent navigations
// and prevent the page from entering the bfcache.
await event.viewTransition.finished;
heading.style.viewTransitionName = '';
}
});
```
## Best Practices
- **DO** assign `view-transition-name` via CSS whenever possible. Reserve JavaScript assignment (via `pagereveal`) for cases where the name depends on navigation context.
- **DO** keep render-blocking scripts small and fast. The browser has a built-in timeout (around 4 seconds), after which the transition is skipped entirely with a `TimeoutError`.
- **DO NOT** use `<link rel="expect">` to block on elements deep in the page that are not visible in the initial viewport. This delays the transition without visual benefit.
- **DO NOT** assign the same `view-transition-name` to multiple elements on the same page. Duplicate names cause the entire transition to be skipped.
- **Assistive Technology Timing Impact**: Using `blocking="render"` delays visual updates and initial paint. While this prevents visual glitches for sighted users, it can cause processing latency or deferred initialization for screen readers and other assistive technologies that depend on rendered accessibility trees. Weigh the visual continuity benefits against the initial read latency for non-visual users, and ensure render-blocking scripts are minimal and extremely optimized.
## Fallback Strategies
Cross-document view transitions has limited availability.
Supported by: Chrome 126 (Jun 2024), Edge 126 (Jun 2024), and Safari 18.2 (Dec 2024).
Unsupported in: Firefox.
Cross-document view transitions are an excellent candidate for progressive enhancement. In browsers that do not support them, the `@view-transition` rule is ignored and standard same-origin navigations occurs exactly as they would without the feature. Supporting browsers get smooth transitions; all others get standard navigation. Limited browser support is not a reason to avoid adoption.
All browsers that support cross-document view transitions also support `blocking="render"` and `<link rel="expect">`, so no separate fallback is needed for the render-blocking features described in this guide.
## Other Considerations
1. **Performance Impact**: Every render-blocking resource delays the view transition animation start. Minimize the number of render-blocking scripts and use `<link rel="expect">` only for elements that are above the fold. Prerender destination pages using the Speculation Rules API to eliminate loading delays entirely.
2. **Timeout Behavior**: If the combined render-blocking time exceeds approximately 4 seconds, the browser skips the transition with a `TimeoutError`. Ensure critical resources load well within this window.
3. **bfcache Compatibility**: Temporary `view-transition-name` assignments that are not cleaned up after the transition can prevent the page from entering the bfcache. Always remove dynamically assigned names in the `finished` callback.
guides/ui-behaviors/cross-document-transitions.md
# Cross-Document Transitions
Cross-document view transitions allow you to create smooth, app-like transitions between different pages of a Multi-Page Application (MPA). By default, the browser performs a cross-fade, but you can customize this to match your site's aesthetic.
### Implementation Steps
#### 1. Opt-in to Cross-Document View Transitions
Both the source and destination pages must opt-in to view transitions for the browser to trigger them on navigation.
```css
/* Respect user's preference for reduced motion */
@media (prefers-reduced-motion: no-preference) {
/* Add to a global stylesheet shared by both pages */
@view-transition {
/* Enables transitions for same-origin navigations */
navigation: auto;
}
}
```
#### 2. Customize Transition Animations (Optional)
You can target the old and new states of the transition using pseudo-elements to create effects like slides or reveals.
```css
/* Customizing the outgoing page animation */
::view-transition-old(root) {
/* Move the old page out to the left */
animation: 0.4s ease-in both slide-out;
}
/* Customizing the incoming page animation */
::view-transition-new(root) {
/* Move the new page in from the right */
animation: 0.4s ease-out both slide-in;
}
@keyframes slide-out {
to { transform: translateX(-20%); opacity: 0; }
}
@keyframes slide-in {
from { transform: translateX(100%); }
}
```
#### 3. Create Directional Transitions (Optional)
You may want different transition effects depending on the pages you are navigating between. For instance, when navigating from a home page to a contact page, you may want the effect of new content coming from the right. When navigating back to the home page, it wouldn't make sense to have the same effect.
If the page you are navigating to will always have the same transition type, regardless of how you get to the page, you can specify it with `types` in the `@view-transition` rule.
```css
@media (prefers-reduced-motion: no-preference) {
@view-transition {
navigation: auto;
/* Specify the types of view transitions that will always be used on this page. */
types: previous;
}
}
```
You can also conditionally specify transition types inside of an event listener for `pagereveal`.
```js
window.addEventListener("pagereveal", async (e) => {
if (e.viewTransition && window.navigation?.activation) {
// Use application-specific logic to compute a transition type
const transitionType = yourTransitionTypeLogic(navigation.activation.from, navigation.activation.entry);
e.viewTransition.types.add(transitionType);
}
});
```
Then, use the `:active-view-transition-type()` pseudo selector to apply the different animations for each type.
```css
:active-view-transition-type(next) {
&::view-transition-old(root) {
animation-name: slide-out-next;
}
&::view-transition-new(root) {
animation-name: slide-in-next;
}
}
:active-view-transition-type(previous) {
&::view-transition-old(root) {
animation-name: slide-out-previous;
}
&::view-transition-new(root) {
animation-name: slide-in-previous;
}
}
```
### Fallback strategies
Baseline status for View transitions: Newly available. It's been Baseline since 2025-10-14.
Supported by: Chrome 111 (Mar 2023), Edge 111 (Mar 2023), Firefox 144 (Oct 2025), and Safari 18 (Sep 2024).
Cross-document view transitions has limited availability.
Supported by: Chrome 126 (Jun 2024), Edge 126 (Jun 2024), and Safari 18.2 (Dec 2024).
Unsupported in: Firefox.
If a browser does not support view transitions, or cross-document view transitions, it will perform a standard instant page navigation. Cross-document view transitions are a progressive enhancement; the core functionality of the site remains unaffected.
To check for support in JavaScript:
```javascript
if ('onpagereveal' in window) {
// Browser supports cross-document view transitions
}
```
Baseline status for Navigation API: Newly available. It's been Baseline since 2026-01-13.
Supported by: Chrome 102 (May 2022), Edge 102 (May 2022), Firefox 147 (Jan 2026), and Safari 26.2 (Dec 2025).
If a browser does not support the Navigation API, you will not be able to use it to determine a transition type. Use an alternate method for determining the transition type, or provide a fallback transition type. Otherwise, the browser will perform a standard instant page navigation.
To check for support in JavaScript:
```javascript
if (window.navigation?.activation) {
// Browser supports the Navigation API
}
```guides/ui-behaviors/custom-button-actions.md
# Custom Button Actions
The Invoker Commands API allows buttons to trigger actions on target elements declaratively using HTML attributes.
This approach reduces the need for manual event listeners and decouples the UI from implementation details.
For custom, application-specific actions, you can define your own command names. Custom commands must be prefixed with a double dash (`--`) to avoid collisions with future built-in browser commands.
## Implementation steps
1. **Define the target element**: Identify the element that will respond to the action. If it doesn’t have a unique `id`, add one.
2. **Configure the invoker button**: Use the `commandfor` attribute to point to the target's `id`, and the `command` attribute to specify the custom command name (prefixed with `--`).
3. **Handle the command event**: Attach a `command` event listener directly on the target element. The event object contains a `command` property and a `target` property (referring to the element identified by `commandfor`).
4. **Handle aria states**: Custom commands do not have inherent semantics, and you must handle states like `aria-pressed` or `aria-expanded`.
## Example: Custom Animation Controls
```html
<!-- The target element that will respond to custom commands -->
<div id="action-target" class="target">
Action Target
</div>
<!-- Buttons declaratively linked to the target element -->
<!-- Each button sends a unique custom command starting with '--' -->
<button commandfor="action-target" command="--spin">
Spin
</button>
<button commandfor="action-target" command="--grow">
Grow
</button>
<button commandfor="action-target" command="--reset">
Reset All
</button>
<script>
// 1. **Optional:** Define a registry of requested actions for cleaner logic
const commandRegistry = {
'--spin': (target, source) => {
const isSpun = target.classList.toggle('is-spun');
// Set ARIA states, as custom commands have no inherent semantics.
source?.setAttribute('aria-pressed', isSpun);
},
'--grow': (target, source) => {
const isGrown = target.classList.toggle('is-grown');
source?.setAttribute('aria-pressed', isGrown);
},
'--reset': (target) => {
target.classList.remove('is-spun', 'is-grown');
// Reset all associated buttons' ARIA states
document.querySelectorAll(`button[commandfor="${target.id}"]`).forEach(btn => {
btn.setAttribute('aria-pressed', 'false');
});
},
};
// 2. **Mandatory:** Listen for the 'command' event directly on the target element
// (This is necessary because the native 'command' event does not bubble)
document.getElementById('action-target').addEventListener('command', (event) => {
const command = event.command;
const target = event.target;
const source = event.source; // event.source refers to the triggering button
const action = commandRegistry[command];
if (action) {
action(target, source);
}
});
</script>
```
## Key constraints
* **Prefix custom commands**: MANDATORY: All custom command names must start with `--` (e.g., `command="--my-action"`).
* **Targeting**: The `commandfor` attribute must match the `id` of an element in the same document tree.
* **No bubbling**: The `command` event does not bubble. If there multiple possible targets, add `{ capture: true }` to the event handler and listen on an ancestor.
* **Shadow roots**: If the target may be in a shadow root, use `event.composedPath()[0]` instead of `event.target`.
* **Accessibility**: Custom commands have no inherent semantics, and you must explicitly apply any states.
## Fallback strategies
Baseline status for Invoker commands: Newly available. It's been Baseline since 2025-12-12.
Supported by: Chrome 135 (Apr 2025), Edge 135 (Apr 2025), Firefox 144 (Oct 2025), and Safari 26.2 (Dec 2025).
If the Invoker Commands API is not supported, the `command` event will not fire. For full support across all modern browsers, it is recommended to use the invokers-polyfill from https://github.com/keithamus/invokers-polyfill via `npm install` or CDN.
This polyfill fully supports custom actions (starting with `--`) and dispatches the `command` event exactly like the native API.
### Dynamic Import (Performance Optimization)
For the best performance, you should only load the polyfill if the browser doesn't support the API natively. This saves bandwidth and reduces script execution time for users on modern browsers.
**NOTE:** This polyfill does not manage ARIA states (like `aria-pressed` or `aria-expanded`) for custom commands. You must manually synchronize these states in your event listener to ensure your site is accessible.
```javascript
// 1. Conditionally load the polyfill
const hasNativeSupport = 'commandForElement' in HTMLButtonElement.prototype;
if (!hasNativeSupport) {
// Wrap in an async IIFE to avoid top-level await issues in older browsers
(async () => {
try {
await import('https://esm.run/invokers-polyfill');
} catch (err) {
console.error('Error loading fallback:', err);
}
})();
}
// 2. Manually manage ARIA states in your listener
document.getElementById('action-target').addEventListener('command', (event) => {
const command = event.command;
const target = event.target;
const source = event.source; // The button that triggered the command
if (command === '--spin') {
const isSpun = target.classList.toggle('is-spun');
// Polyfill tip: Manually update ARIA to match the new state
source?.setAttribute('aria-pressed', isSpun);
}
});
```
### Manual fallback (Traditional pattern)
If you prefer not to use a polyfill, you can use a combination of **event delegation** to dispatch events and a **command registry** to handle the actions. This is a common architectural pattern in traditional JavaScript development that remains highly efficient and scalable.
```javascript
// 1. **Optional:** Define a registry of requested actions for cleaner logic
const commandRegistry = {
'--spin': (target) => target.classList.toggle('is-spun'),
'--grow': (target) => target.classList.toggle('is-grown'),
'--reset': (target) => target.classList.remove('is-spun', 'is-grown'),
};
// 2. If CommandEvent doesn't exist, we assume no native support and provide the fallback
if (!globalThis.CommandEvent) {
globalThis.CommandEvent = class CommandEvent extends Event {
constructor(type, { source, command, ...options } = {}) {
super(type, options);
this.source = source;
this.command = command;
}
}
}
// 3. The fallback: Dispatch events manually if native support is missing
document.addEventListener('click', (event) => {
const button = event.composedPath().find((el) => el.matches?.("button[commandfor]"));
if (!button) return;
const target = document.getElementById(button.getAttribute('commandfor'));
const command = button.getAttribute('command');
if (target && command) {
target.dispatchEvent(new CommandEvent('command', {
command,
source: button,
}));
}
});
// 4. **Mandatory:** Register the unified listener directly on the target element
document.getElementById('action-target').addEventListener('command', (event) => {
const command = event.command;
const target = event.target;
const action = commandRegistry[command];
if (action) {
action(target);
}
});
```
guides/ui-behaviors/declarative-dialog-popover-control.md
# Declarative Dialog and Popover Control
Use the Invoker Commands API to toggle the visibility of `<dialog>` and `[popover]` elements directly from HTML buttons, eliminating the need for custom JavaScript event listeners.
By applying the `commandfor` (target ID) and `command` (action) attributes to a `<button>`, the browser automatically handles open/close state changes, focus management, and accessibility bindings (such as `aria-expanded`). This declarative approach is recommended because it removes brittle boilerplate code, ensures interactions are functional immediately upon HTML parsing, and guarantees a robust, natively accessible user experience.
## Implementing Declarative Popovers
Popovers can be toggled open and closed using a single button.
```html
<!-- MANDATORY: The commandfor attribute links the invoker to the ID of the target element so the browser knows what to control. -->
<!-- MANDATORY: The command attribute specifies the action to perform. Use 'toggle-popover' to handle both open and close states automatically. -->
<button commandfor="my-popover" command="toggle-popover">
Toggle Popover
</button>
<!-- MANDATORY: The target element must have the popover attribute to be controlled as a popover. -->
<div id="my-popover" popover>
<p>Popover content goes here.</p>
</div>
```
If you need to control opening and closing with separate buttons, you can use the `show-popover` and `hide-popover` commands.
```html
<!-- MANDATORY: Use 'show-popover' to explicitly open the popover. It will not close the popover if clicked again. -->
<button commandfor="my-explicit-popover" command="show-popover">
Show Popover
</button>
<div id="my-explicit-popover" popover="manual">
<p>This popover is explicitly opened and closed by separate buttons.</p>
<!-- MANDATORY: Use 'hide-popover' to explicitly close the targeted popover. -->
<button commandfor="my-explicit-popover" command="hide-popover">
Hide Popover
</button>
</div>
```
## Implementing Declarative Modal Dialogs
Unlike popovers, modal dialogs typically use separate buttons for opening and closing. Use the `show-modal` command specifically when you need to open a dialog as a modal.
```html
<!-- MANDATORY: Use command="show-modal" to trigger the dialog as a modal, trapping focus and preventing interaction with the rest of the page. -->
<!-- MANDATORY: The commandfor attribute connects this button to the dialog ID. -->
<button commandfor="confirm-dialog" command="show-modal">
Open Confirmation
</button>
<dialog id="confirm-dialog">
<p>Are you sure you want to proceed?</p>
<!-- MANDATORY: Use command="close" to dismiss the dialog safely. -->
<button commandfor="confirm-dialog" command="close">
Cancel
</button>
</dialog>
```
## Fallback strategies
Baseline status for Invoker commands: Newly available. It's been Baseline since 2025-12-12.
Supported by: Chrome 135 (Apr 2025), Edge 135 (Apr 2025), Firefox 144 (Oct 2025), and Safari 26.2 (Dec 2025).
Because Invoker Commands and Popovers are not yet universally supported, you MUST use polyfills as fallbacks for older browsers.
### Polyfilling Invoker Commands
MANDATORY: Feature detect support by checking for the `commandForElement` property on the `HTMLButtonElement` prototype. Do NOT check the window or document object. You MUST dynamically import the polyfill only when the native feature is missing. DO NOT unconditionally load the polyfill.
Mandatory: Listen for the 'command' event directly on the target element because the native 'command' event does not bubble.
**Option 1: Using a bundler**
Install the polyfill via npm (`npm install invokers-polyfill`). This approach is for projects using a bundler (like Vite or Webpack) or import maps. For all other setups, use the CDN option below.
```javascript
// MANDATORY: Feature detect 'commandForElement' on HTMLButtonElement.prototype.
// Conditionally load the invokers-polyfill only in browsers lacking native support.
if (!('commandForElement' in HTMLButtonElement.prototype)) {
import('invokers-polyfill');
}
```
**Option 2: Using a CDN**
For projects without a bundler, dynamically import the polyfill directly from a CDN inside a `<script type="module">`.
```html
<script type="module">
// MANDATORY: Feature detect 'commandForElement' on HTMLButtonElement.prototype.
// Conditionally load the invokers-polyfill from a CDN only in browsers lacking native support.
if (!('commandForElement' in HTMLButtonElement.prototype)) {
import('https://esm.run/invokers-polyfill');
}
</script>
```
**Invokers Polyfill Limitations**
MANDATORY: This polyfill does not handle the ARIA states (e.g., `aria-expanded`) of the command button the way native browsers do. You are strongly encouraged to handle these states yourself to ensure your site is fully accessible.
Baseline status for Invoker commands: Newly available. It's been Baseline since 2025-12-12.
Supported by: Chrome 135 (Apr 2025), Edge 135 (Apr 2025), Firefox 144 (Oct 2025), and Safari 26.2 (Dec 2025).
If the Invoker Commands API is not supported, the `command` event will not fire. For full support across all modern browsers, it is recommended to use the invokers-polyfill from https://github.com/keithamus/invokers-polyfill via `npm install` or CDN.
This polyfill fully supports custom actions (starting with `--`) and dispatches the `command` event exactly like the native API.
### Dynamic Import (Performance Optimization)
For the best performance, you should only load the polyfill if the browser doesn't support the API natively. This saves bandwidth and reduces script execution time for users on modern browsers.
**NOTE:** This polyfill does not manage ARIA states (like `aria-pressed` or `aria-expanded`) for custom commands. You must manually synchronize these states in your event listener to ensure your site is accessible.
```javascript
// 1. Conditionally load the polyfill
const hasNativeSupport = 'commandForElement' in HTMLButtonElement.prototype;
if (!hasNativeSupport) {
// Wrap in an async IIFE to avoid top-level await issues in older browsers
(async () => {
try {
await import('https://esm.run/invokers-polyfill');
} catch (err) {
console.error('Error loading fallback:', err);
}
})();
}
// 2. Manually manage ARIA states in your listener
document.getElementById('action-target').addEventListener('command', (event) => {
const command = event.command;
const target = event.target;
const source = event.source; // The button that triggered the command
if (command === '--spin') {
const isSpun = target.classList.toggle('is-spun');
// Polyfill tip: Manually update ARIA to match the new state
source?.setAttribute('aria-pressed', isSpun);
}
});
```
### Manual fallback (Traditional pattern)
If you prefer not to use a polyfill, you can use a combination of **event delegation** to dispatch events and a **command registry** to handle the actions. This is a common architectural pattern in traditional JavaScript development that remains highly efficient and scalable.
```javascript
// 1. **Optional:** Define a registry of requested actions for cleaner logic
const commandRegistry = {
'--spin': (target) => target.classList.toggle('is-spun'),
'--grow': (target) => target.classList.toggle('is-grown'),
'--reset': (target) => target.classList.remove('is-spun', 'is-grown'),
};
// 2. If CommandEvent doesn't exist, we assume no native support and provide the fallback
if (!globalThis.CommandEvent) {
globalThis.CommandEvent = class CommandEvent extends Event {
constructor(type, { source, command, ...options } = {}) {
super(type, options);
this.source = source;
this.command = command;
}
}
}
// 3. The fallback: Dispatch events manually if native support is missing
document.addEventListener('click', (event) => {
const button = event.composedPath().find((el) => el.matches?.("button[commandfor]"));
if (!button) return;
const target = document.getElementById(button.getAttribute('commandfor'));
const command = button.getAttribute('command');
if (target && command) {
target.dispatchEvent(new CommandEvent('command', {
command,
source: button,
}));
}
});
// 4. **Mandatory:** Register the unified listener directly on the target element
document.getElementById('action-target').addEventListener('command', (event) => {
const command = event.command;
const target = event.target;
const action = commandRegistry[command];
if (action) {
action(target);
}
});
```
### Fallbacks & browser support for Popover
Baseline status for Popover: Newly available. It's been Baseline since 2025-01-27.
Supported by: Chrome 116 (Aug 2023), Edge 116 (Aug 2023), Firefox 125 (Apr 2024), Safari 17 (Sep 2023), and Safari iOS 18.3 (Jan 2025).
The Popover API is mostly **progressive enhancement**, but its defining behaviors — top-layer promotion, light-dismiss, and `popovertarget` invocation — have no CSS-only equivalent. Older browsers need a polyfill, or a manual fallback if you would rather not ship one.
**Polyfill:** To support the `popover` attribute in older browsers, conditionally load [`@oddbird/popover-polyfill`](https://github.com/oddbird/popover-polyfill). **MANDATORY:** Feature detect by checking for the `popover` property on `HTMLElement.prototype`, and load the polyfill **only** when native support is missing — do NOT load it unconditionally.
With a bundler or import map:
```js
// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
import("@oddbird/popover-polyfill");
}
```
Without a bundler, import from a CDN inside a `<script type="module">`:
```html
<script type="module">
if (!("popover" in HTMLElement.prototype)) {
import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
}
</script>
```
**Styling caveat:** The polyfill cannot define the real `:popover-open` pseudo-class, so it applies a `.\:popover-open` class instead. **MANDATORY:** Combine the two with `:is()` or `:where()`, otherwise browsers that lack `:popover-open` discard the entire rule:
```css
[popover]:is(:popover-open, .\:popover-open) {
display: block;
}
```
Alternatively, for a legacy fallback without a polyfill, use `position: fixed` and manually calculate coordinates via `getBoundingClientRect()` or rely on default positioning with `inset: auto` if that's acceptable for the use case.
guides/ui-behaviors/directional-navigation-transitions.md
# Directional Navigation Transitions
Single Page Applications (SPAs) provide the appearance of navigation by replacing the content of the page without navigating to a new page. By default, the content is simply replaced, without any transitions. Directional transitions can visually reinforce a spatial relationship between views.
By sliding new content in from the direction the user is moving you create a mental map of the application structure. For instance, a product site may show a transition to the right for "forward," and to the left for "back", or a slideshow may transition up and down to show next and previous slides.
### Implementation Steps
1. **Detect Navigation Direction**: Determine if the user is moving "forward" or "backward" in the application flow. How you detect the direction depends on your use case.
2. **Trigger Transition with Types**: Pass the direction in a `types` array to `document.startViewTransition()` to categorize the transition.
3. **Define Directional Animations with CSS**: Use the `:active-view-transition-type()` pseudo-class to apply specific animations based on the navigation type.
### Defining Keyframes
Define sliding animations to and from each direction. For best performance, animate position changes using the `transform` property or the individual transform properties, `scale`, `rotate`, and `translate`. `opacity` is generally performant as well, but avoid animating other CSS properties without first verifying that they don't trigger layout or painting.
```css
/* Slide an element out to the left */
@keyframes slide-to-left {
/* Mandatory: animate `transform` instead of inset properties for better performance. */
to { transform: translateX(-100%); }
}
/* Slide an element in from the right */
@keyframes slide-from-right {
from { transform: translateX(100%); }
}
/* Slide an element out to the right */
@keyframes slide-to-right {
to { transform: translateX(100%); }
}
/* Slide an element in from the left */
@keyframes slide-from-left {
from { transform: translateX(-100%); }
}
```
### Set up shared animation settings
Use the `::view-transition-group(root)` selector to apply animation settings that are shared across all transitions.
```css
::view-transition-group(root){
animation: 0.4s ease-in-out both;
}
```
### Applying Directional Animations
Use the `active-view-transition-type` pseudo-class to target the transition views specifically when the "forward" or "backward" type is active.
```css
/* MANDATORY: Apply forward animations when the 'forward' type is active */
html:active-view-transition-type(forward)::view-transition-old(root) {
animation-name: slide-to-left;
}
html:active-view-transition-type(forward)::view-transition-new(root) {
animation-name: slide-from-right;
}
/* MANDATORY: Apply backward animations when the 'backward' type is active */
html:active-view-transition-type(backward)::view-transition-old(root) {
animation-name: slide-to-right;
}
html:active-view-transition-type(backward)::view-transition-new(root) {
animation-name: slide-from-left;
}
```
### Triggering the Transition
When navigating, pass the appropriate type to the `startViewTransition` method.
```javascript
const transitionType = yourTransitionTypeLogic();
const updateDOM = yourUpdateDOMLogic();
document.startViewTransition({
update: updateDOM,
types: [transitionType] // Matches the CSS :active-view-transition-type() selectors
});
```
### Accessibility
Always respect user preferences for reduced motion by disabling or simplifying animations.
```css
@media (prefers-reduced-motion: reduce) {
::view-transition-group(root) {
animation: none !important;
}
}
```
### Fallback strategies
Baseline status for View transitions: Newly available. It's been Baseline since 2025-10-14.
Supported by: Chrome 111 (Mar 2023), Edge 111 (Mar 2023), Firefox 144 (Oct 2025), and Safari 18 (Sep 2024).
Baseline status for Active view transition: Newly available. It's been Baseline since 2026-01-13.
Supported by: Chrome 125 (May 2024), Edge 125 (May 2024), Firefox 147 (Jan 2026), and Safari 18.2 (Dec 2024).
The View Transitions API is a progressive enhancement. In unsupported browsers, `document.startViewTransition` will be `undefined`. You must wrap your navigation logic in a feature detection check to ensure the DOM update still occurs immediately without animation, as shown in this helper function.
```javascript
/**
* Navigates to a new view with a directional transition.
* @param {Function} updateDOM - Callback to update the DOM state.
* @param {string} direction - Either 'forward' or 'backward'.
*/
function navigate(updateDOM, direction) {
// Feature detect for browsers that do not support View Transitions
if (!document.startViewTransition) {
updateDOM();
return;
}
// Start transition with the specific navigation type
document.startViewTransition({
update: updateDOM,
types: [direction] // Matches the CSS :active-view-transition-type() selectors
});
}
```
guides/ui-behaviors/dynamic-sibling-animations.md
# Creating a stagger animation
Stagger animations provide an interesting effect where multiple ordered elements animate sequentially with a slight delay between each, rather than all animating at once. This technique is often used in lists, galleries, or navigation menus to guide the user's eye and add a polished, rhythmic feel to interactions.
## Stagger animations with `sibling-index()`
Use the `sibling-index()` property on the `animation-delay` property so that the animation on each element is offset by a number proportionate to their position in their parent. The `sibling-index()` function returns an integer, so it must be multiplied by a time unit to convert it to a time.
```css
#stagger-list > .item {
--stagger-time: 0.1s;
/* Define the animation first */
animation: fade-in 0.4s;
/* Set the `animation-delay` to a time multipled by the `sibling-index()` */
animation-delay: calc(sibling-index() * var(--stagger-time))
}
```
**MANDATORY:** Respect user preferences by disabling the animation for users who prefer reduced motion.
```css
@media (prefers-reduced-motion: reduce){
/* Disable animation for users who prefer reduced motion. */
#stagger-list > .item {
animation: none;
}
}
```
## Fallback strategies
Baseline status for sibling-count() and sibling-index(): Newly available. It's been Baseline since 2026-08-18.
Supported by: Chrome 138 (Jun 2025), Edge 138 (Jun 2025), Firefox 154, and Safari 26.2 (Dec 2025).
Test for support for `sibling-index()` using CSS with `@supports (animation-delay: calc(sibling-index() * 0.1s)){}` or JavaScript with `!CSS.supports('animation-delay: calc(sibling-index() * 0.1s)')`.
To support stagger animations in older browsers, use JavaScript to add a `--sibling-index` custom property to each sibling element. MANDATORY: wrap this in a `CSS.supports('animation-delay: calc(sibling-index() * 0.1s)')` test to avoid running unneeded JavaScript.
```js
if(!CSS.supports('animation-delay: calc(sibling-index() * 0.1s)')){
const staggerList = document.getElementById('stagger-list');
[...staggerList.children].forEach((el, index)=>el.style.setProperty('--sibling-index', index + 1));
}
```
Add an `animation-delay` declaration that uses the `--sibling-index` custom property. It must be before the `animation-delay` declaration that uses the `sibling-index()` function. This does not need to be wrapped in `@supports` - older browsers will not parse the second declaration and will use the first declaration.
```css
#stagger-list > .item {
animation-delay: calc(var(--sibling-index) * var(--stagger-time));
animation-delay: calc(sibling-index() * var(--stagger-time));
}
```
guides/ui-behaviors/group-element-transitions.md
# Group Element Transitions
As items are added or removed from a list, or rearranged, transitions can help users maintain context. View transitions provide a way to transition between two states of an element by giving the element a unique `view-transition-name`. When multiple elements on a page share the same transition behavior, `view-transition-class` allows you to define that logic once in CSS rather than repeating it for every unique `view-transition-name`. This keeps your stylesheets maintainable while ensuring consistent animations across a group of elements.
### Implementation steps
1. **Assign unique names and a shared class**
Each element that needs to be tracked individually during a transition must have a unique `view-transition-name`.
```html
<!-- Mandatory: Each element must have a unique view-transition-name -->
<li style="view-transition-name: item-1" class="item">Item 1</li>
<li style="view-transition-name: item-2" class="item">Item 2</li>
```
To apply shared styles, also assign a `view-transition-class`.
```css
.item {
view-transition-class: list-item;
}
```
2. **Define the shared transition logic**
Use the `::view-transition-group()` pseudo-element with the class selector to apply styles to all members of that group.
```css
/* Targets any view transition group that has the 'list-item' class */
::view-transition-group(.list-item) {
animation-duration: 0.5s;
animation-timing-function: ease-in-out;
}
/* Handle accessibility by respecting motion preferences */
@media (prefers-reduced-motion: reduce) {
/* Disable all group transitions, including the default `root` group. */
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
```
3. **Optional: Define entry and exit animations**
Use the `:only-child` selector to add specific transitions to the elements that are added or removed. `::view-transition-new()` and `::view-transition-old()` pseudo-elements are children of a `::view-transition-image-pair()` pseudo-element, so we can determine it is an added or removed element if it is the only child.
```css
/* A `::view-transition-new()` element is the only child if it wasn't present before the view transition, so it is an added element. */
::view-transition-new(.list-item):only-child {
animation-name: slide-in;
/* Specify an animation duration if you want something different than the UA default of 0.5s */
animation-duration: 1s;
}
/* A `::view-transition-old()` element is the only child if it isn't present after the view transition, so it is a removed element. */
::view-transition-old(.list-item):only-child {
animation-name: slide-out;
/* Specify an animation duration if you want something different than the UA default of 0.5s */
animation-duration: 1s;
}
@keyframes slide-in {
from {
translate: -100vw 0;
}
}
@keyframes slide-out {
to {
translate: -100vw 0;
}
}
```
4. **Trigger the transition**
Wrap the DOM update in `document.startViewTransition()`. The browser will capture the old state, perform the update, and then animate to the new state.
```javascript
function updateList(newData) {
document.startViewTransition(() => {
// All DOM changes inside this callback will be transitioned
render(newData);
});
}
```
5. **Maintain interactivity of non-transitioned elements**
View transitions work by overlaying snapshots of the DOM elements, and then transitioning the snapshots. This means that during the transition, elements are not interactive. If there are interactive elements that are not transitioned, you can make them interactive by disabling touch events on the view transitions.
```css
::view-transition {
/* Non-transitioned elements below the view transitions remain interactive */
pointer-events: none;
}
```
In addition, by default, the `:root` element has a view transition named `root`, which enables default full-page transitions. If there are no changes to the root element, this will be a transition between two identical snapshots, which are not interactive. Because we are only transitioning specific elements, and not the entire screen, we can disable the `root` transition.
```css
:root {
/* Disable the root transition because we are only transitioning specific elements. */
view-transition-name: none;
}
```
### Fallback strategies
Baseline status for view-transition-class: Newly available. It's been Baseline since 2025-10-14.
Supported by: Chrome 125 (May 2024), Edge 125 (May 2024), Firefox 144 (Oct 2025), and Safari 18.2 (Dec 2024).
View Transitions are a progressive enhancement. If the browser does not support `document.startViewTransition`, the DOM update should still occur immediately, providing a functional but non-animated experience.
```javascript
if (document.startViewTransition) {
document.startViewTransition(() => updateDOM());
} else {
// Fallback: Perform the update without animation
updateDOM();
}
```
For CSS, browsers that do not recognize `view-transition-class` or the `::view-transition-group()` class selector will simply ignore those rules, and no animation will be applied.
guides/ui-behaviors/highlight-text-ranges.md
# Highlight Text Ranges
The CSS Custom Highlight API lets you style arbitrary text ranges on a page without modifying the DOM structure. This enables search-result highlighting, syntax coloring, collaborative editing cursors, or spelling and grammar error markers without wrapping text in extra elements or relying on `innerHTML` manipulation.
### Core implementation
To highlight text ranges, you must collect the target text nodes, create `Range` and `Highlight` objects, register them in the `HighlightRegistry`, and then style them with the `::highlight()` pseudo-element.
#### 1. Collect text nodes and create ranges
Use a `TreeWalker` to collect all text nodes in the target element, then create `Range` objects pointing at the character offsets you want to highlight.
```javascript
const article = document.querySelector("article");
// MANDATORY: Use TreeWalker to collect text nodes — do not manipulate innerHTML.
const treeWalker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
const allTextNodes = [];
let currentNode = treeWalker.nextNode();
while (currentNode) {
allTextNodes.push(currentNode);
currentNode = treeWalker.nextNode();
}
// MANDATORY: Set range start/end on text nodes, not element nodes.
const range = new Range();
range.setStart(textNode, matchStartIndex);
range.setEnd(textNode, matchEndIndex);
```
Cache the text-node list and only rebuild it when the DOM content actually changes, since walking the tree is expensive.
#### 2. Create a Highlight from the ranges
Group one or more `Range` objects into a `Highlight`. Multiple ranges that share the same style belong in a single highlight.
```javascript
const searchHighlight = new Highlight(...matchingRanges);
```
#### 3. Register the highlight in the registry
Register each `Highlight` under a custom name using `CSS.highlights`, which is a `Map`-like `HighlightRegistry`.
```javascript
// MANDATORY: Clear previous highlights before registering new ones
// to avoid stale ranges persisting on the page.
CSS.highlights.clear();
CSS.highlights.set("search-results", searchHighlight);
```
When multiple highlights overlap, use the `priority` property to control stacking order. Higher priority highlights paint on top.
```javascript
const primary = new Highlight(...primaryRanges);
primary.priority = 1;
const secondary = new Highlight(...secondaryRanges);
secondary.priority = 0; // painted first (behind primary)
CSS.highlights.set("primary", primary);
CSS.highlights.set("secondary", secondary);
```
#### 4. Style with `::highlight()`
Use the `::highlight()` pseudo-element in CSS to style each registered highlight by name.
```css
::highlight(search-results) {
background-color: #ffdd00;
color: black;
}
```
Only a limited set of CSS properties work inside `::highlight()`: `color`, `background-color`, `text-decoration` and its longhands, `text-shadow`, `-webkit-text-stroke-color`, `-webkit-text-fill-color`, and `-webkit-text-stroke-width`. Properties like `background-image`, `font-size`, or `padding` are ignored.
### Accessibility
**AVOID**: using custom highlights as a replacement for semantic HTML.
Custom highlights are purely presentational and are not exposed to the accessibility tree. If the highlighted text is semantically relevant to the document (e.g., a user-selected passage), use `<mark>` instead. Reserve custom highlights for transient, visual-only effects like search results or syntax coloring.
Highlights should not rely solely on color to convey meaning. If a highlight indicates an error, pair it with another visual indicator such as `text-decoration: wavy underline` or an adjacent text label. Ensure sufficient contrast between the highlight background and text color to meet WCAG 2.1 requirements (at least 4.5:1 for normal text).
### Fallback strategies
Baseline status for Custom highlights: Newly available. It's been Baseline since 2026-03-24.
Supported by: Chrome 105 (Sep 2022), Edge 105 (Sep 2022), Firefox 149 (Mar 2026), and Safari 17.2 (Dec 2023).
For browsers that do not support the CSS Custom Highlight API, you should provide a functional base experience where text is still legible, even without the visual highlight.
You can detect support before using the API:
```javascript
if (CSS.highlights) {
// CSS Custom Highlight API is supported.
} else {
// Fallback: wrap matches in <mark> elements.
}
```
If the highlight is critical for the user experience, fall back to wrapping matched text in `<mark>` elements. This modifies the DOM, so take care to preserve event listeners and avoid breaking the document structure.
```javascript
if (!CSS.highlights) {
// Walk text nodes and wrap matches in <mark>, preserving structure.
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
const nodes = [];
for (let n = walker.nextNode(); n; n = walker.nextNode()) nodes.push(n);
const term = searchTerm.toLowerCase();
for (const textNode of nodes) {
const text = textNode.textContent;
let pos = text.toLowerCase().indexOf(term);
if (pos === -1) continue;
const frag = document.createDocumentFragment();
let last = 0;
while (pos !== -1) {
frag.append(text.slice(last, pos));
const mark = document.createElement("mark");
// textContent assignment avoids HTML injection.
mark.textContent = text.slice(pos, pos + term.length);
frag.append(mark);
last = pos + term.length;
pos = text.toLowerCase().indexOf(term, last);
}
frag.append(text.slice(last));
textNode.replaceWith(frag);
}
}
```
guides/ui-behaviors/interactive-content-reveal.md
# Interactive Content Reveal
Add performant, interactive reveal effects to your site with CSS masks and registered custom properties. By using a radial gradient as a mask and registering its stop values, we can smoothly transition the entry and exit, while following a user's pointer with minimal JavaScript.
## Implementation
### 1. Register Custom Properties
To enable smooth interpolation of gradient stop values, you must register the variables using `@property`. This informs the browser's engine about the data type, allowing it to transition between values during updates.
```css
/* Register the spotlight inner and outer sizes to enable interpolation */
@property --inner-size{
syntax: "<length-percentage>";
inherits: true;
initial-value: 0px;
}
@property --outer-size{
syntax: "<length-percentage>";
inherits: true;
initial-value: 0px;
}
```
The custom properties tracking the pointer position do not need to be transitioned, so it is not required to register them.
### 2. Define the Masking Layer
Apply the `mask-image` to the element you want to reveal. Use a `radial-gradient` that references the registered properties.
```css
.reveal-layer {
/* Only transition the size properties, NOT the position variables */
transition: --inner-size 0.2s ease-in-out, --outer-size 0.2s ease-in-out;
/* The spotlight is defined by the transparent center of the mask */
mask-image: radial-gradient(
circle at var(--mouse-x) var(--mouse-y),
black var(--inner-size, 0%),
transparent var(--outer-size, 0%)
);
/* Ensure the mask doesn't repeat if the element is large */
mask-repeat: no-repeat;
/* Make the mask layer non-interactive */
pointer-events: none;
}
/* Update the gradients stops on interaction */
.reveal-layer:hover {
--inner-size: 100px;
--outer-size: 120px;
}
```
### 3. Update Coordinates with JavaScript
Track the pointer position and update the CSS variables. Because the properties are registered and have a `transition` defined, the spotlight will move smoothly even if the pointer events are infrequent.
```javascript
const container = document.querySelector('.container');
// Store the container's bounding rect
let rect = container.getBoundingClientRect();
// Update the rect when the container is resized
const resizeObserver = new ResizeObserver(()=>{
rect = container.getBoundingClientRect();
})
resizeObserver.observe(container);
container.addEventListener('pointermove', (e) => {
// Calculate position as a percentage of the container.
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
// Update the registered properties
container.style.setProperty('--mouse-x', `${x}%`);
container.style.setProperty('--mouse-y', `${y}%`);
});
```
### 4. Accessibility and Interaction
**MANDATORY Accessibility Guarantee:** This pattern relies on pointer interactions to reveal a visual spotlight. You MUST guarantee that all underlying content remains fully visible, legible, and independently keyboard-reachable by default in the underlying layout, using the spotlight layer purely as a non-essential visual enhancement for pointer users. Never use this effect to obscure or gate essential content from keyboard-only or assistive technology users.
* **Pointer Events:** Set `pointer-events: none` on the mask overlay layer to allow standard click and touch interactions to pass through to controls underneath.
* **Reduced Motion Override:** Disable smooth transition interpolation for users requesting reduced motion.
```css
/* MANDATORY Copy-Paste Safety: Disable transition scaling for motion-sensitive users */
@media (prefers-reduced-motion: reduce) {
.reveal-layer {
transition: none !important;
}
}
```
## Fallback strategies
Baseline status for Registered custom properties: Newly available. It's been Baseline since 2024-07-09.
Supported by: Chrome 85 (Aug 2020), Edge 85 (Aug 2020), Firefox 128 (Jul 2024), and Safari 16.4 (Mar 2023).
### Non-registered Property Fallback
Browsers that support `mask-image` but not `@property` will still show the spotlight, but the movement will jump between on and off states because they cannot interpolate values inside a `radial-gradient`. Provide fallback values when using `var()`.
```css
.reveal-layer {
mask-image: radial-gradient(
circle at var(--mouse-x) var(--mouse-y),
/* Use fallback values when using the `var()` function for browsers that don't get an initial value from the @property registration. */
black var(--inner-size, 0%),
transparent var(--outer-size, 0%)
);
}
```
Baseline status for Masks: Widely available. It's been Baseline since 2023-12-07.
Supported by: Chrome 120 (Dec 2023), Edge 120 (Dec 2023), Firefox 53 (Apr 2017), and Safari 15.4 (Mar 2022).
### Basic Mask Support
For browsers that do not support CSS masking at all:
1. **Prefixed property:** Use the `-webkit-mask-image` prefixed property for broader browser support.
2. **Progressive Enhancement:** Design the base state of the UI to be fully functional and legible without the reveal effect. This is useful when the effect is only adds visual flair, and not a requirement for reading content.
guides/ui-behaviors/interest-triggered-action-previews.md
# Interest Triggered Action Previews
It can be beneficial to provide users a preview of their actions before they commit to them. Interest invokers are an experimental web platform feature that provides a declarative-based way of creating interest relationships between an interest source (i.e. a button or a link) and an interest target. Once the declarative relationship has been established there are a number of methods a developer can respond to based on interest and loss of interest using both CSS and JavaScript. For this use case, we can leverage the `interest` and `loseinterest` events to preview various effects for an interest target.
## How to implement
An interest relationship is created by setting the `interestfor` attribute on a button or link (i.e. the interest source). The attribute takes an ID reference to another element (i.e. the interest target). Interest invokers or sources can only have a single interest target, but an interest target can have multiple interest invokers.
```html
<!-- MANDATORY: interest relationships must be established with the `interestfor` attribute on a button or a link -->
<button interestfor="interestingElement" data-effect="A">Some effect</button>
<button interestfor="interestingElement" data-effect="B">Some other effect</button>
<div id="interestingElement">Something interesting</div>
```
For the sake of this use case, we can leverage the `interest` and `loseinterest` events to preview various effects for an interest target. Both of these events are `InterestEvent`s which has a `source` property which is the source of the interest (i.e. the element with the `interestfor` attribute).
```javascript
interestingElement.addEventListener("interest", event => {
// Apply the preview based on `event.source`
event.target.dataset.preview = event.source.dataset.effect;
});
interestingElement.addEventListener("loseinterest", event => {
// Unapply the preview
delete event.target.dataset.preview;
});
```
> [!NOTE]
> **Don't announce interest-driven previews via a live region.** Interest can be triggered just by moving the pointer across the page or tabbing through nearby buttons, so apply/unapply announcements quickly become noise that can drown out content the user actually cares about. The preview itself is the affordance; users who can perceive it benefit directly, and those who can't will not benefit from a verbal echo of "Previewing effect: A". If you decide you genuinely need an announcement for a specific case, make sure to test it carefully with users.
Active interest sources and targets can be selected with CSS using the `:interest-source` and `:interest-target` pseudo-selectors respectively.
```css
/* Styles to apply when the effect is being previewed */
:interest-source {}
:interest-target {}
```
The start and end delay for an interest invoker (i.e. the element with the `interestfor` attribute) can be set with the `interest-delay-start` and `interest-delay-end` CSS properties or the shorthand `interest-delay` property.
```css
[interestfor] {
interest-delay-start: 0.2s;
interest-delay-end: 0.1s;
}
```
### Fallback strategies
Interest invokers has limited availability.
Supported by: Chrome 142 (Oct 2025) and Edge 142 (Oct 2025).
Unsupported in: Firefox and Safari.
Interest invokers must be conditionally polyfilled using the `interestfor` polyfill package from NPM. Do prefer bundling the polyfill over using the CDN.
```html
<script type="module">
if(!HTMLButtonElement.prototype.hasOwnProperty("interestForElement")){
// CDN link only used for example, prefer bundling.
await import("https://unpkg.com/interestfor@latest");
}
</script>
```
When using the polyfill the CSS API changes slightly for the `:interest-source` and `:interest-target` pseudo-classes, as well as, the `interest-delay`, `interest-delay-start`, and `interest-delay-end` properties:
```css
/* Styles to apply when the effect is being previewed */
:is(:interest-source, .interest-source) {}
:is(:interest-target, .interest-target) {}
/* Adjust the start and end delay for interest invokers */
[interestfor] {
--interest-delay-start: 0.2s;
interest-delay-start: var(--interest-delay-start);
--interest-delay-end: 0.1s;
interest-delay-end: var(--interest-delay-end);
}
```guides/ui-behaviors/interest-triggered-tooltips.md
# Show a tooltip when hovering
Users expect to see additional related information without completely changing their context. Showing a tooltip when a user is interested in more information can be useful to provide definitions for a term, clarifying the action an icon-only button will take, or provide additional form field guidance.
## Creating the tooltip
You can create a popover with the required behavior by adding the `popover="hint"` attribute to a `<div>` or other semantically appropriate element. When the user opens the tooltip, this hides other `popover="hint"` tooltips, but doesn't hide `auto` or `manual` tooltips. It also handles dismissing nested tooltips.
It also provides light dismiss behavior, so when a user clicks or otherwise focuses outside of the popover, the popover is dismissed.
The tooltip element must have an `id` attribute with a unique value:
```html
<!-- MANDATORY: The tooltip container `<div>` must have a `popover` attribute.
the value of `"hint"` ensures it can be "light dismissed". -->
<div popover="hint" id="tooltip">Tooltip content</div>
```
A user expresses interest in the additional information by hovering or focusing on an `<a>` or `<button>` element. The element must have an `interestfor` attribute that matches the `id` attribute of the tooltip.
```html
<!-- The `interestfor` attribute can be applied to a `<button>` element: -->
<button interestfor="tooltip">Tooltip trigger</button>
<!-- The `interestfor` attribute can also be applied to an `<a>` element: -->
<a interestfor="tooltip" href="">Tooltip trigger</a>
```
The trigger must have a visual indicator to indicate that there is additional information available by interacting with the trigger.
### Accessibility built in to `interestfor`
`interestfor` handles the assistive-technology wiring for you, so you generally do not need to add ARIA attributes manually:
- A target with `popover="hint"` gains an implicit minimum role of `tooltip`. **DO NOT** set `role="tooltip"` yourself.
- The browser implicitly associates the source element with the target via `aria-describedby` when the target is plaintext, or via `aria-details` when the target contains interactive content. **DO NOT** add `aria-describedby` or `aria-details` to the trigger.
- Because the association switches to `aria-details` when needed, the target IS allowed to contain interactive content (e.g. a link inside an "interest card").
### Accessibility Constraints (WCAG 1.4.13)
Even with `interestfor` handling the semantics above, your implementation MUST still satisfy WCAG 1.4.13 (Content on Hover or Focus):
- **Dismissible:** Users must be able to dismiss the tooltip without moving pointer hover or keyboard focus (e.g., by pressing the `Escape` key). The native `popover` attribute manages this binding automatically.
- **Hoverable:** The pointer must be able to move over the tooltip content itself without the tooltip disappearing. This allows users with magnification tools to read the tooltip text safely.
- **Persistent:** The tooltip must remain visible until the hover or focus trigger is removed, the user explicitly dismisses it, or its content is no longer valid.
### Positioning the tooltip
The tooltip can be positioned using anchor positioning. When the tooltip is opened using `interestfor`, the trigger becomes an implicit anchor for the tooltip, meaning you don't have to add `anchor-name` or `position-anchor` CSS properties. However, to support browsers without anchor positioning you must use the anchor positioning polyfill, which has several limitations for popovers. **MANDATORY:** Implicit anchors are NOT supported by the polyfill, so YOU MUST explicitly set an `anchor-name` on the trigger and `position-anchor` on the popover.
```css
/* MANDATORY: use explicit anchor names for compatibility with the polyfill */
button[interestfor="tooltip-dom"] {
anchor-name: --tooltip-dom;
}
#tooltip-dom {
position-anchor: --tooltip-dom;
}
```
Also, the polyfill does not support `position-area` on popovers, so **MANDATORY:** DO position using `anchor()` functions, and **YOU MUST** include a `position-try` fallback (e.g. `flip-block` or `flip-inline`).
```css
[popover]{
/* MANDATORY: use anchor functions and a position-try fallback for the polyfill */
top: anchor(bottom);
left: anchor(center);
position-try: flip-block;
margin: unset;
}
```
### Fallback strategies
Interest invokers has limited availability.
Supported by: Chrome 142 (Oct 2025) and Edge 142 (Oct 2025).
Unsupported in: Firefox and Safari.
Interest invokers must be conditionally polyfilled using the `interestfor` polyfill package from NPM. Do prefer bundling the polyfill over using the CDN.
```html
<script type="module">
if(!HTMLButtonElement.prototype.hasOwnProperty("interestForElement")){
// CDN link only used for example, prefer bundling.
await import("https://unpkg.com/interestfor@latest");
}
</script>
```
### Fallbacks & browser support for Popover
Baseline status for Popover: Newly available. It's been Baseline since 2025-01-27.
Supported by: Chrome 116 (Aug 2023), Edge 116 (Aug 2023), Firefox 125 (Apr 2024), Safari 17 (Sep 2023), and Safari iOS 18.3 (Jan 2025).
The Popover API is mostly **progressive enhancement**, but its defining behaviors — top-layer promotion, light-dismiss, and `popovertarget` invocation — have no CSS-only equivalent. Older browsers need a polyfill, or a manual fallback if you would rather not ship one.
**Polyfill:** To support the `popover` attribute in older browsers, conditionally load [`@oddbird/popover-polyfill`](https://github.com/oddbird/popover-polyfill). **MANDATORY:** Feature detect by checking for the `popover` property on `HTMLElement.prototype`, and load the polyfill **only** when native support is missing — do NOT load it unconditionally.
With a bundler or import map:
```js
// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
import("@oddbird/popover-polyfill");
}
```
Without a bundler, import from a CDN inside a `<script type="module">`:
```html
<script type="module">
if (!("popover" in HTMLElement.prototype)) {
import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
}
</script>
```
**Styling caveat:** The polyfill cannot define the real `:popover-open` pseudo-class, so it applies a `.\:popover-open` class instead. **MANDATORY:** Combine the two with `:is()` or `:where()`, otherwise browsers that lack `:popover-open` discard the entire rule:
```css
[popover]:is(:popover-open, .\:popover-open) {
display: block;
}
```
Alternatively, for a legacy fallback without a polyfill, use `position: fixed` and manually calculate coordinates via `getBoundingClientRect()` or rely on default positioning with `inset: auto` if that's acceptable for the use case.
popover="hint" has limited availability.
Supported by: Chrome 151, Edge 151, and Firefox 153 (Jul 2026).
Unsupported in: Safari.
The `popover-polyfill` does not polyfill the hint behavior in browsers that support `popover` but not `popover="hint"`. For those browsers, a tooltip opened via focus may stay open when a second tooltip opened via hover.
Anchor positioning is not natively supported by any major browser yet.
**MANDATORY:** To support browsers without anchor positioning, you MUST use the `@oddbird/css-anchor-positioning` polyfill. It does not support implicit anchors, so you MUST add anchor names to the trigger. Additionally, `position-area` is not supported on popovers by the polyfill, so you MUST use `anchor()` on the desired insets.
```html
<!-- MANDATORY: Conditionally install the anchor positioning polyfill -->
<script type="module">
if (!("anchorName" in document.documentElement.style)) {
await import("https://unpkg.com/@oddbird/css-anchor-positioning");
}
</script>
```
```css
button[interestfor="tooltip-attrs"] {
/* MANDATORY: Each trigger and popover pair must have a unique anchor name, referenced by `anchor-name` on the trigger and `position-anchor` on the popover. */
anchor-name: --tooltip-attrs;
}
#tooltip-attrs {
position-anchor: --tooltip-attrs;
/* If using the anchor positioning polyfill with a popover, DO use `anchor()` functions, and not `position-area. */
top: anchor(bottom);
left: anchor(right);
margin: unset;
}
```guides/ui-behaviors/light-dismiss-a-dialog.md
# Light-Dismiss a Dialog
Modern modal dialogs often support "light-dismiss," allowing users to close a dialog by clicking or tapping the backdrop (the area outside the dialog). The `closedby` attribute provides a declarative way to enable this behavior without custom JavaScript.
## Implementation
To enable light-dismiss:
1. Add `closedby="any"` to the `<dialog>` element.
2. Open the dialog using `dialog.showModal()`.
### Attribute Values
- `any`: Enables light-dismiss (clicking the backdrop), "close requests" (the `Esc` key), and developer mechanisms (e.g., `dialog.close()`).
- `closerequest`: Enables "close requests" and developer mechanisms only. This is the default for modal dialogs.
- `none`: Only developer mechanisms can close the dialog.
### Styling the Backdrop
When a dialog is opened as a modal using `showModal()`, the browser generates a `::backdrop` pseudo-element. This backdrop covers the entire viewport and sits directly behind the dialog.
```css
/* Style the backdrop to indicate the dialog is modal */
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px); /* Optional: add blur for modern browsers */
}
```
## Example
```html
<!-- MANDATORY: Use closedby="any" to enable light-dismiss behavior -->
<dialog id="myDialog" closedby="any" aria-labelledby="dialogTitle">
<form method="dialog">
<h2 id="dialogTitle">Feedback</h2>
<p>Click outside this box or press Esc to dismiss.</p>
<button type="submit">Close</button>
</form>
</dialog>
<button onclick="document.getElementById('myDialog').showModal()">Open Dialog</button>
```
## Constraints & Accessibility
- **MANDATORY**: Use `closedby="any"` to enable light-dismiss declaratively.
- **MANDATORY**: Always open modal dialogs with `showModal()`. This ensures the dialog is in the top layer, focus is trapped, and the `Esc` key is handled.
- **DO**: Use `aria-labelledby` or `aria-label` to provide an accessible name for the dialog.
- **DO NOT**: Use `closedby` for non-modal dialogs (opened with `show()`), as they do not have a backdrop and won't trigger light-dismiss.
- **DO NOT**: Use the `click` event for critical logic that should happen *before* closing; instead, listen for the `close` or `cancel` events.
## Fallback strategies
<dialog closedby> has limited availability.
Supported by: Chrome 134 (Mar 2025), Edge 134 (Mar 2025), and Firefox 141 (Jul 2025).
Unsupported in: Safari.
**MANDATORY**: For browsers that do not yet support `closedby`, you **must** implement a fallback for light-dismiss by checking if a click occurred outside the dialog content's boundaries using the following script:
```javascript
const dialog = document.querySelector('dialog');
// Fallback for browsers without closedby support
if (!('closedBy' in HTMLDialogElement.prototype)) {
dialog.addEventListener('click', (event) => {
// 1. When clicking the backdrop, the event target is the dialog element itself.
// Ignore clicks where the target is a child element inside the dialog.
if (event.target !== dialog) return;
// 2. Check if the click coordinates fall within the dialog's content box.
// This distinguishes between a click on the backdrop vs a click on the dialog's background/padding.
const rect = dialog.getBoundingClientRect();
const isDialogContent = (
rect.top <= event.clientY &&
event.clientY <= rect.top + rect.height &&
rect.left <= event.clientX &&
event.clientX <= rect.left + rect.width
);
if (isDialogContent) return;
// 3. Since the click was outside the content area (on the backdrop), manually close the dialog.
dialog.close();
});
}
```
guides/ui-behaviors/move-dom-element-without-losing-state.md
# Move DOM Element Without Losing State
When reparenting DOM elements using traditional methods like `appendChild()` or `insertBefore()`, the browser implicitly removes the element from the DOM and then inserts it into its new location. This "remove and insert" operation resets many internal states, causing `<iframe>` elements to reload, CSS animations to restart, and input fields to lose focus.
To move an element while preserving its state, use the `moveBefore()` API. This method performs an atomic move, completely bypassing the removal and insertion steps.
### Moving an element with state
Use `moveBefore()` exactly as you would use `insertBefore()`. It requires two arguments: the node to move, and a reference node to insert before (or `null` to append to the end of the new parent).
```javascript
const newParent = document.getElementById('new-parent');
const elementWithState = document.getElementById('iframe-or-focused-input');
// MANDATORY: Use moveBefore to preserve state.
// Passing null as the second argument appends the element to the end of newParent.
newParent.moveBefore(elementWithState, null);
```
### Moving custom elements (Web Components)
If you are moving custom elements using `moveBefore()`, their `connectedCallback` and `disconnectedCallback` lifecycle methods will **not** be fired.
If your custom element needs to perform specific logic when moved, implement the `connectedMoveCallback()` method inside the custom element definition.
```javascript
class MyCustomElement extends HTMLElement {
connectedCallback() {
// Runs on initial insertion.
}
connectedMoveCallback() {
// Runs when the element is moved via moveBefore().
// Use this to update state that depends on the new DOM location.
}
}
```
### Fallback strategies
moveBefore() has limited availability.
Supported by: Chrome 133 (Feb 2025), Edge 133 (Feb 2025), and Firefox 144 (Oct 2025).
Unsupported in: Safari.
Since `moveBefore()` is a progressive enhancement, you MUST use feature detection before calling it, falling back to traditional `insertBefore()` or `appendChild()` operations for older browsers.
```javascript
const targetParent = document.getElementById('target-container');
const nodeToMove = document.getElementById('moving-element');
// Check if moveBefore is supported on the Element prototype
if ('moveBefore' in Element.prototype) {
targetParent.moveBefore(nodeToMove, null);
} else {
// Fallback: traditional move.
// Note: This WILL reset <iframe>, animation, and focus state in unsupported browsers.
targetParent.insertBefore(nodeToMove, null);
}
```guides/ui-behaviors/parallax-scroll-effects.md
# Build a Parallax Effect on Scroll
A parallax effect on scroll is a visual technique where different layers of content move at varying speeds as the user scrolls down a page. This creates an illusion of depth, with foreground elements appearing to move faster than the background elements, resulting in an engaging and immersive browsing experience. This effect is best achieved using CSS Scroll-Driven Animations, which allow you to link animations to the scroll position of a container.
## How to implement
Here’s how to create a basic parallax effect:
1. **Create a wrapper element:** This element simply groups all the layers of the parallax effect together. It is not the scrollable element, so its overflow should be clipped. Also give it a `height` that matches the height of one of the layers of the parallax effect.
```html
<div class="wrapper">
…
</div>
```
```css
.wrapper {
overflow: clip;
height: 100vh; /* Height of one of the layers of the parallax */
}
```
2. **Declare the layers:** Inside the wrapper, add the individual layers that will move at different speeds.
```html
<div class="wrapper">
<div class="layer">LAYER 0</div>
<div class="layer">LAYER 1</div>
<div class="layer">LAYER 2</div>
…
</div>
```
3. **Add a translate animation:** Define a CSS animation that changes the `transform` property of the layers. For a parallax effect, you'll typically use `translateY` to move the layers vertically.
```css
@keyframes parallax {
from {
transform: translateY(700px);
}
}
```
4. **Set up the `view-timeline`:** To link the animation to the scroll position, create a `view-timeline` on the wrapper element and then apply it to the layers.
```css
.wrapper {
view-timeline: --wrapper;
}
.layer {
animation: parallax linear both;
animation-timeline: --wrapper;
}
```
5. **Stagger the animations:** To make the layers move at different speeds, you can use one of two main approaches: **staggering in the keyframes**, or **staggering the `animation-range`**.
Both of these approaches can use hardcoded values, or can use the `sibling-index()`/`sibling-count()` implementation. The hardcoded values are easiest and also useful when having only a limited amount of layers. The `sibling-index()`/`sibling-count()` implementation is handy when you have many layers.
* **Staggering in the keyframes:**
Using **hardcoded values**, you can define a custom property for each layer to manually control its parallax offset.
```css
.layer:nth-child(1) { --offset: 100px; }
.layer:nth-child(2) { --offset: 200px; }
.layer:nth-child(3) { --offset: 300px; }
@keyframes parallax {
from {
transform: translateY(var(--offset));
}
}
```
Using **`sibling-index()`**, let the `sibling-index()` function return the index of a child element amongst its siblings to automatically calculate the staggered effect.
```css
@keyframes parallax {
from {
transform: translateY(calc(100px * sibling-index()));
}
}
```
* **Staggering the `animation-range`:**
Using **hardcoded values**, you can explicitly define the boundaries of the `animation-range` on each layer individually.
```css
.layer:nth-child(1) { animation-range: entry 25% exit 50%; }
.layer:nth-child(2) { animation-range: entry 25% exit 75%; }
.layer:nth-child(3) { animation-range: entry 25% exit 100%; }
```
Using **`sibling-index()` and `sibling-count()`**, you can calculate the range mathematically based on the total number of layers (`sibling-count()`).
```css
.layer {
animation-range: entry 25% exit calc(100% / sibling-count() * sibling-index());
}
```
## Example code
```css
@keyframes parallax {
from {
transform: translateY(calc(100px * sibling-index()));
}
}
.wrapper {
view-timeline: --wrapper;
}
.layer {
animation: parallax linear both;
animation-timeline: --wrapper;
}
@media (prefers-reduced-motion: reduce) {
.layer {
animation: none;
}
}
```
Alternatively, you can use the `animation-range` property to achieve a similar effect:
```css
@keyframes parallax {
from {
transform: translateY(700px);
}
}
.wrapper {
view-timeline: --wrapper;
}
.layer {
animation: parallax linear both;
animation-timeline: --wrapper;
animation-range: entry 25% exit calc(100% / sibling-count() * sibling-index());
}
@media (prefers-reduced-motion: reduce) {
.layer {
animation: none;
}
}
```
## Best Practices
When using scroll-driven animations, it's important to follow a few best practices to ensure a smooth and accessible experience:
- **DO** include feature detection: Not all browsers support scroll-driven animations. Use `@supports ((animation-timeline: view()) and (animation-range: entry))` to check for support and provide a fallback for browsers that don't support it.
- The `(animation-range: entry)` check **MUST** be included here, to filter out browsers with only partial support.
- **DO NOT** use the `scroll-timeline-polyfill` package for the fallback strategy as it is not feature complete and has a lot of known issues.
- If the animation is only considered to be decorative, opt for Progressive Enhancement and **DO NOT** provide a fallback.
- **DO** respect user preferences: Some users prefer to have less motion on the web. Use the `prefers-reduced-motion` media query to disable or reduce your animations for these users.
- **DO** try to animate only performant CSS properties: For the smoothest animations, stick to animating properties that can be handled by the browser's compositor thread, such as `transform` and `opacity`. Animating other properties like `width` or `height` can lead to performance issues.
- **DO** use the correct declaration order: When using the `animation` shorthand property, declare `animation-timeline` and `animation-range` *after* it to prevent the shorthand from resetting the timeline.
As for setting the `animation-range`:
- **DO** give all layers the same start offset, e.g. `entry 25%`
- **DO** give all layers a different end offset that uses `sibling-count()` and `sibling-index()` to distribute the offsets, e.g. `exit calc(100% / sibling-count() * sibling-index())`.
## Browser support and fallback strategies
Scroll-driven animations has limited availability.
Supported by: Chrome 115 (Jul 2023), Edge 115 (Jul 2023), and Safari 26 (Sep 2025).
Unsupported in: Firefox.. Therefore, a fallback strategy is typically required.
For browsers that do not support scroll-driven animations, you can use a fallback to recreate the visual effects. The fallbacks are typically built with either a scroll listener (for ScrollTimeline effects) or the IntersectionObserver API (for ViewTimeline effects).
In browsers with built-in support for scroll-driven animations, ALWAYS use the native CSS implementation as those are more performant.
Note that not every effect can be recreated using the fallbacks approach.
For this use-case specifically, the following script applies the fallback for browsers that do not support scroll-driven animations. It uses an `IntersectionObserver` to track the visibility of the `.wrapper` element and updates the `transform` property of the layers based on the scroll position.
```js
// Fallback for browsers that don't support scroll-driven animations
if (!CSS.supports('(animation-timeline: view()) and (animation-range: entry)')) {
const wrapper = document.querySelector('.wrapper');
const layers = document.querySelectorAll('.layer');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
window.addEventListener('scroll', onScroll);
} else {
window.removeEventListener('scroll', onScroll);
}
});
}, { threshold: 0 });
observer.observe(wrapper);
function onScroll() {
const scrollY = window.scrollY;
const wrapperRect = wrapper.getBoundingClientRect();
const wrapperTop = wrapperRect.top + scrollY;
const wrapperHeight = wrapperRect.height;
const windowHeight = window.innerHeight;
if (scrollY >= wrapperTop - windowHeight && scrollY <= wrapperTop + wrapperHeight) {
const scrollPercent = (scrollY - (wrapperTop - windowHeight)) / (wrapperHeight + windowHeight);
layers.forEach((layer, index) => {
// This matches the effect as defined in the CSS example above.
// Customize this further if needed.
const initialTranslateY = 100 * index;
const translateY = initialTranslateY * (1 - scrollPercent);
layer.style.transform = `translateY(${translateY}px)`;
});
}
}
// Trigger onScroll once to set initial positions
onScroll();
}
```
guides/ui-behaviors/persistent-top-layer-ui.md
# Persistent Top Layer UI
When moving an open `<dialog>`, `popover`, or fullscreen element in the DOM using traditional methods like `appendChild()` or `insertBefore()`, the browser implicitly removes the element from the DOM and re-inserts it. This removal resets the state, causing open modals, popovers, and fullscreen elements to close abruptly.
To reparent top-layer elements without interrupting the user experience or closing them, use the atomic `moveBefore()` API instead.
### Reparenting open top-layer elements
`moveBefore()` takes two arguments: the node to move, and a reference node to insert before (or `null` to append to the end of the new parent).
```javascript
const newParent = document.getElementById('new-container');
const dialogElement = document.getElementById('my-dialog');
// MANDATORY: Use moveBefore to ensure the <dialog> or popover stays open.
// Passing null appends it to the end of newParent.
newParent.moveBefore(dialogElement, null);
```
### Fallback strategies
moveBefore() has limited availability.
Supported by: Chrome 133 (Feb 2025), Edge 133 (Feb 2025), and Firefox 144 (Oct 2025).
Unsupported in: Safari.
Since `moveBefore()` is a progressive enhancement, you MUST use feature detection before calling it. For older browsers, you will have to fallback to traditional reparenting.
**MANDATORY**: For `<dialog>` elements in unsupported browsers, the traditional move will close the dialog. If you need it to remain open, you must manually re-open it after the move.
```javascript
const targetParent = document.getElementById('target-container');
const popoverOrDialog = document.getElementById('my-top-layer-element');
// Check if moveBefore is supported
if ('moveBefore' in Element.prototype) {
targetParent.moveBefore(popoverOrDialog, null);
} else {
// Fallback: traditional move.
// Note: This WILL close <dialog>, popover, and fullscreen elements.
const wasOpen = popoverOrDialog.hasAttribute('open') || popoverOrDialog.matches(':popover-open');
targetParent.insertBefore(popoverOrDialog, null);
// Manually restore state if possible
if (wasOpen && typeof popoverOrDialog.showModal === 'function') {
popoverOrDialog.showModal();
} else if (wasOpen && typeof popoverOrDialog.showPopover === 'function') {
popoverOrDialog.showPopover();
}
}
```guides/ui-behaviors/physics-based-easing.md
# Physics Based Easing
Traditional CSS easing functions like `ease-in` or `cubic-bezier()` are limited to simple curves, making it impossible to create complex physics-based effects like bounces or springs. The `linear()` timing function solves this by allowing you to provide a series of stops that can approximate complex curves. Transitions and animations are interpolated based on straight lines between the stops, but within enough stops, it can appear smooth.
### Implementation Steps
1. **Generate the curve stops:**
Manually plotting dozens of points for a spring or bounce is impractical. Use a timing function from an external library, or use a tool to convert an existing JavaScript easing function or an SVG path into the `linear()` syntax. Optional: store these timing functions as CSS custom properties for reuse throughout your site.
2. **Define the timing function:**
Apply the generated stops to the `transition-timing-function` or `animation-timing-function` property, or through the `transition` or `animation` shorthands.
3. **Adjust the duration:**
Unlike JavaScript physics engines where duration is derived from physical properties (mass, stiffness), CSS still requires a fixed `duration`. You may need to adjust the duration to get the intended effect.
### Example: Spring Easing
This example shows how to use a custom `linear()` function to create a spring effect that overshoots the target value before settling.
```css
.spring {
/* Define the physics-based easing as a reusable variable */
--spring-easing: linear(0, 0.016 0.5%, 0.06 1%, 0.226 2%, 1.116 5.4%, 1.375 6.6%, 1.527 7.7%, 1.565 8.2%, 1.585 8.8%, 1.581 9.3%, 1.559 9.8%, 1.458 10.9%, 0.937 14.3%, 0.784 15.5%, 0.693 16.6%, 0.67 17.1%, 0.657 17.7%, 0.671 18.7%, 0.729 19.8%, 1.042 23.3%, 1.13 24.5%, 1.182 25.6%, 1.201 26.7%, 1.192 27.7%, 1.156 28.8%, 0.977 32.2%, 0.925 33.4%, 0.894 34.5%, 0.882 35.6%, 0.887 36.6%, 0.907 37.7%, 1.045 42.4%, 1.069 44.5%, 1.059 46.3%, 0.979 50.9%, 0.96 53.4%, 0.966 55.3%, 1.013 59.9%, 1.024 62.3%, 0.986 71.2%, 1.008 79.9%, 0.995 88.9%, 1);
/* Apply the easing with a duration that fits the spring's complexity */
/* MANDATORY: Always include a duration; linear() does not calculate it automatically */
transition: scale 0.8s var(--spring-easing);
}
.spring:hover {
scale: 1.2;
}
```
### Example: Bounce Easing
This example shows how to use a custom `linear()` function to create a bounce effect.
```css
.bounce {
/* Define the physics-based easing as a reusable variable */
--bounce-easing: linear(0, 0.214 14.7%, 0.386 23.7%, 0.598 31.9%, 0.999 44.7%, 0.807 52.6%, 0.762 56%, 0.747 59.4%, 0.758 62.4%, 0.793 65.6%, 0.999 77.4%, 0.961 81.2%, 0.949 84.8%, 0.956 88%, 0.993 95.5%, 1);
/* Apply the easing with a duration that fits the bounce's complexity */
/* MANDATORY: Always include a duration; linear() does not calculate it automatically */
transition: scale 0.4s var(--bounce-easing);
}
.bounce:hover {
scale: 1.2;
}
```
### Key Considerations
* **Performance:** For the smoothest physics-based animations, apply `linear()` to properties that run on a separate thread, such as `transform` and `opacity`.
* **Precision vs. Payload:** While more stops result in a smoother curve, they also increase the size of your CSS. Most generators allow you to "simplify" the curve to find the optimal balance between smoothness and code size.
* **Avoid Opacity for Bounces:** Applying bounce easings to `opacity` can cause visually jarring flickering if the value overshoots below 0 or above 1.
* **Accessibility:** Complex physics-based animations can be distracting or cause motion sensitivity for some users. Always respect user preferences by reducing or disabling these animations.
```css
@media (prefers-reduced-motion: reduce) {
.element {
transition: none;
}
}
```
### Fallback strategies
Baseline status for linear() easing: Widely available. It's been Baseline since 2023-12-11.
Supported by: Chrome 113 (May 2023), Edge 113 (May 2023), Firefox 112 (Apr 2023), and Safari 17.2 (Dec 2023).
#### CSS Fallback
For browsers that do not support `linear()`, provide a standard easing function as a fallback. The browser will ignore the `linear()` value if it doesn't recognize it, falling back to the previous valid declaration.
```css
.element {
/* Fallback for older browsers (standard smooth exit) */
transition: transform 0.8s ease-out;
/* Modern browsers will override with the physics-based easing */
transition-timing-function: linear(0, 1.1, 0.95, 1.02, 1);
}
```
#### JavaScript Library Fallback (Motion/GSAP)
Optional: If a high-fidelity physics animation is critical even in older browsers, use a JavaScript library like **Motion** (motion.dev) or **GSAP** (greensock.com) to handle the animation when `linear()` is unsupported.
1. **Detect support:** Use `CSS.supports()` to check if the browser handles the `linear()` function.
2. **Conditionally load/apply:** If unsupported, use the library's spring or bounce implementation.
```javascript
/* Detect if the browser supports the linear() function */
const supportsLinearEasing = window.CSS && CSS.supports('animation-timing-function', 'linear(0, 1)');
if (!supportsLinearEasing) {
/*
Example using Motion (motion.dev) for a spring fallback.
This should only be initialized if native CSS support is missing.
*/
import("https://cdn.jsdelivr.net/npm/motion@latest/dist/motion.js").then(({ animate, spring }) => {
animate(".element", { transform: "scale(1.2)" }, {
easing: spring({ stiffness: 100, damping: 10 })
});
});
}
```
You can also use `@supports` in CSS for more explicit feature detection:
```css
@supports not (animation-timing-function: linear(0, 1)) {
.element {
/* Alternative experience for unsupported browsers */
transition-duration: 0.4s;
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
}
}
```
guides/ui-behaviors/platform-controls-dismiss-dialog.md
# Platform Controls Dismiss Dialog
When a modal dialog is open, users expect to use familiar controls to dismiss them: pressing the <kbd>Esc</kbd> key on a keyboard, using the back button or gesture on mobile platforms, or a dismiss gesture with assistive technologies.
When the `<dialog>` element was first introduced, it could be dismissed with the <kbd>Esc</kbd> key, but not other platform controls such as a back button/gesture on mobile. With the addition of the `closedby` attribute for `<dialog>` elements, the extended behavior of responding to more platform-specific controls for close requests has been applied for `<dialog>` elements that are opened in a modal state (i.e. when opened imperatively with the `<dialog>` element’s `showModal()` method in JavaScript or declaratively with the `show-modal` invoker command). So, there is no specific change developers need to make if they are already using the `<dialog>` element.
```html
<!-- MANDATORY: must be opened with either `showModal()` with JavaScript or the `show-modal` command using declarative command invokers in order respond to close requests including platform-specific controls. -->
<dialog aria-labelledby="example">
<h1 id="example">Example</h1>
<p>Modal that can be dismissed with close requests.</p>
</dialog>
```
When opened in a modal state, a dialog without the `closedby` attribute responds to close requests the same as explicitly setting the `closedby` attribute to `closerequest`.
```html
<!-- This is unnecessary as it is the default behavior for modal dialogs -->
<dialog closedby="closerequest" aria-labelledby="example">
<h1 id="example">Example</h1>
<p>Modal that can be dismissed with close requests.</p>
</dialog>
```
If you also want “light dismiss” behavior, then you must set `closedby` to `any`:
```html
<dialog closedby="any" aria-labelledby="example">
<h1 id="example">Example</h1>
<p>Modal that can be dismissed with close requests and light dismiss.</p>
</dialog>
```
## Fallback strategies
<dialog closedby> has limited availability.
Supported by: Chrome 134 (Mar 2025), Edge 134 (Mar 2025), and Firefox 141 (Jul 2025).
Unsupported in: Safari.
`<dialog>` elements opened in a modal state can already be dismissed with <kbd>Esc</kbd>, so there is no fallback necessary. There is no good way to implement close requests from mobile back button/gestures, so it is simpler to embrace this feature as a progressive enhancement, especially given that there are other inclusive means to dismiss the modal dialog. Similarly, light dismiss behavior for a `<dialog>` element using `closedby="any"` can be considered a progressive enhancement.
guides/ui-behaviors/same-document-transitions.md
# Same Document Transitions
## The Problem
Web sites often provide multiple views of an object, for instance a list of products, and then a detail page for each product. Navigating between the two views often feels disconnected. When a user clicks a product thumbnail to view its details, the thumbnail disappears and a new, larger image appears instantly elsewhere on the screen. This lack of continuity makes it harder for users to track relationships between elements.
## The Solution
The **View Transitions API** allows you to specify element pairs that exist in different states before and after a transition. When triggering a transition with `document.startViewTransition()` in a Single Page Navigation (SPA), the browser identifies these shared elements by their shared unique `view-transition-name`. It then automatically calculates the difference in their position, size, and styling, and animates them smoothly from the old state to the new state. This transition occurs in the top layer, above even elements with high `z-index` values.
## Implementation Guide
### Step 1: Wrap State Changes in `startViewTransition`
For Single-Page Applications (SPAs) or simple state changes, wrap the logic that updates the DOM in `document.startViewTransition`. The browser captures a snapshot of the current state, runs the update, and then captures the new state.
```javascript
function navigate(view) {
// MANDATORY: Wrap the update in startViewTransition
document.startViewTransition(() => updateDOM(view));
}
```
### Step 2: Assign Shared Transition Names
Use the `view-transition-name` CSS property to tell the browser which elements should be morphed. The name can be anything (except `none`). **MANDATORY**: there must be no more than 1 element before and after with a given `view-transition-name`. If there are 2 or more elements with a given `view-transition-name`, the DOM will be updated to the new state immediately, without a transition.
You can use multiple `view-transition-name`s to morph multiple pairs of elements. For example, you may want to transition both the product image and title with separate transitions.
Because there are multiple items on the list view, you can not give the all of them the same `view-transition-name`. This can be solved in two ways in a SPA.
1. **Dynamic detail page:** Assign each item on the list page a unique `view-transition-name`, and then dynamically apply that name to the matching element on the detail page when the list item is selected, as shown here.
```css
/* In the list view, give each */
#product-1 { view-transition-name: p1 }
#product-2 { view-transition-name: p2 }
#product-3 { view-transition-name: p3 }
```
```js
function updateDOM(clickedTransitionName){
const hero = document.getElementById("hero");
hero.style.viewTransitionName = clickedTransitionName;
}
```
2. **Dynamic list item:** Assign the element on the detail page a `view-transition-name`, and apply that name to the item on the list page when it is selected. Remove the `view-transition-name` from the item on the list page when returning to the list page.
The `#hero` element on the detail page and the selected `.thumbnail` element on the list page share a `view-transition-name`.
```css
#hero{
view-transition-name: hero;
}
.thumbnail.selected {
view-transition-name: hero;
}
```
When a thumbnail is clicked, we need to prepare the list view by assigning the `view-transition-name` using the `.selected` class selector, and making any changes to the DOM before starting the transition.
Then, you can call `document.startViewTransition()`, and apply the changes to transition the page from the detail to list view.
After navigating back to the list view, you must clean up the view transition classes to prevent the next navigation from erroring. You can perform this cleanup after the transition's `finished` promise resolves.
```javascript
// Function called when a thumbnail is clicked
function goFromListToDetail(e){
e.currentTarget.classList.add("selected");
const hero = document.getElementById("hero");
const bgColor = getComputedStyle(e.currentTarget).backgroundColor;
hero.style.background = bgColor;
// Trigger the transition, checking for support
if (!document.startViewTransition) {
document.body.classList.add("detail");
// MANDATORY Accessibility Routing: Route focus to the newly revealed heading to announce context and preserve logical tab flow
document.getElementById("detail-heading")?.focus();
return; // MANDATORY: End function execution if view transitions are not supported.
}
const transition = document.startViewTransition(() => {
document.body.classList.add("detail");
});
// MANDATORY Accessibility Routing: Route focus after the view transition resolves
transition.finished.finally(() => {
document.getElementById("detail-heading")?.focus();
});
}
// Function called when navigating from detail back to list view
function goFromDetailToList() {
if (!document.startViewTransition) {
document.body.classList.remove("detail");
document.getElementById("list-heading")?.focus();
return;
}
const transition = document.startViewTransition(() => {
document.body.classList.remove("detail");
});
// Clean up the list view and route focus
transition.finished.finally(() => {
// Route focus back to list view
document.getElementById("list-heading")?.focus();
// Remove selected classList to remove view-transition-names
document.querySelectorAll(".selected").forEach(
(element) => {
element.classList.remove("selected");
},
);
});
}
```
The method you choose will depend on the use case. The dynamic list item requires less repeated CSS, but more manual JavaScript cleanup.
### Step 3: Fix Aspect Ratio "Stretching"
By default, the browser cross-fades the old and new snapshots within a group that stretches to fit both. If you are transitioning text, set the width of the text element to `fit-content` on both the old and new views, so that the transitioned element's aspect ratio is stable.
```css
#list-page .title {
width: fit-content;
}
#detail-page #title {
width: fit-content;
}
```
If you are transitioning elements that change aspect ratio, you may need to set the height of the old and new pseudo-elements to 100% of the `::view-transition-pair()` pseudo-element.
```css
::view-transition-old(hero),
::view-transition-new(hero){
height: 100%;
}
```
The pseudo-elements are snapshots of the live elements, so you can also use `object-fit` and `object-position` declarations for more control of the transitioning effect.
## Best Practices
- **DO NOT** specify too many transitions. Only use shared elements for primary content that the user is actively tracking (e.g., hero images, headings).
- **DO** remove temporary `view-transition-name` values after the transition finishes to avoid side effects on future transitions.
- **DO NOT** transition elements with active animations. View transitions operate on snapshots, so any animations will appear to be paused during the view transition.
- **DO** respect user preferences for reduced motion using the `prefers-reduced-motion` media query.
- **MANDATORY Accessibility Routing**: View transitions morph page layouts dynamically but do not manage programmatic focus. If focus remains on an element that is hidden or removed during the transition, focus is abandoned, leaving keyboard and assistive technology users without context. Shift focus programmatically to an updated page heading or view container (using `tabindex="-1"`) immediately after the DOM updates or when the view transition's `finished` promise resolves.
```css
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
```
## Fallback Strategies
Baseline status for View transitions: Newly available. It's been Baseline since 2025-10-14.
Supported by: Chrome 111 (Mar 2023), Edge 111 (Mar 2023), Firefox 144 (Oct 2025), and Safari 18 (Sep 2024).
The View Transitions API is designed for progressive enhancement. Browsers that do not support it will simply execute the DOM update immediately without animation.
```javascript
function navigate(){
if (!document.startViewTransition) {
// Fallback: Just update the DOM
updateDOM();
} else {
document.startViewTransition(() => updateDOM());
}
}
```
guides/ui-behaviors/scroll-entry-exit-effects.md
# Add entry and exit effects to elements as they enter or exit the scrollport
Entry and exit effects are animations that are triggered when an element enters or leaves the viewport. This can be used to create engaging and dynamic user experiences. For example, you can use an entry effect to fade in an element as it scrolls into view, or an exit effect to scale it down as it scrolls out of view.
## How to implement
To add entry and exit effects to an element, you need to combine a few CSS properties. Here’s a step-by-step guide:
1. **Create separate `@keyframes` for the entry and exit animations.** The entry animation will be applied as the element enters the viewport, and the exit animation will be applied as it leaves.
```css
@keyframes slide-in {
from { transform: translateX(-100%); }
}
@keyframes slide-out {
to { transform: translateX(100%); }
}
```
2. **Attach the entry and exit keyframes to the element.** You can do this by defining multiple animations in the `animation` property.
- Give the entry animation an `animation-fill-mode` of `backwards` so that it applies its initial state before the animation starts.
- Give the exit animation an `animation-fill-mode` of `forwards` so that it maintains its final state after the animation is complete.
```css
.animated-element {
animation:
slide-in 1s linear backwards,
slide-out 1s linear forwards;
}
```
3. **Create a View Timeline and link it to the animations.** A View Timeline is a type of timeline that is linked to the visibility of an element in the viewport. You can create one using the `view()` function and then apply it to your animations using the `animation-timeline` property.
```css
.animated-element {
animation-timeline: view();
}
```
By default, `view()` tracks the element on the `block` axis. If you need to track it on the `inline` axis, you can use `view(inline)`.
4. **Limit the animations to the `entry` and `exit` ranges.** The `animation-range` property allows you to specify which part of the timeline an animation should run on.
- The `entry` range covers the time from when the element first enters the viewport until it is fully visible.
- The `exit` range covers the time from when the element starts to leave the viewport until it is completely hidden.
```css
.animated-element {
animation-range: entry, exit;
}
```
## Example code
This code animates the direct children of the scroller on scroll using an **anonymous view-timeline**:
```css
@media (prefers-reduced-motion: no-preference) {
@supports ((animation-timeline: view()) and (animation-range: entry)) {
@keyframes grow {
from {
scale: 0.5;
}
}
@keyframes shrink {
to {
scale: 0.5;
}
}
.scroller > * {
animation:
grow auto linear backwards,
shrink auto linear forwards;
animation-timeline: view(inline);
animation-range: entry, exit;
}
}
}
```
As the elements enter the scrollport the `grow` animation is played, and as they leave the scrollport the `shrink` animation is played.
The following code has the same visual outcome, but animates the direct children of the scroller on scroll using an **named view-timeline**:
```css
@media (prefers-reduced-motion: no-preference) {
@supports ((animation-timeline: view()) and (animation-range: entry)) {
@keyframes grow {
from {
scale: 0.5;
}
}
@keyframes shrink {
to {
scale: 0.5;
}
}
.scroller > * {
view-timeline: --tl inline;
animation:
grow auto linear backwards,
shrink auto linear forwards;
animation-timeline: --tl;
animation-range: entry, exit;
}
}
}
```
## Best Practices
When using scroll-driven animations, it's important to follow a few best practices to ensure a smooth and accessible experience:
- **DO** include feature detection: Not all browsers support scroll-driven animations. Use `@supports ((animation-timeline: view()) and (animation-range: entry))` to check for support and provide a fallback for browsers that don't support it.
- The `(animation-range: entry)` check **MUST** be included here, to filter out browsers with only partial support.
- **DO NOT** use the `scroll-timeline-polyfill` package for the fallback strategy as it is not feature complete and has a lot of known issues.
- If the animation is only considered to be decorative, opt for Progressive Enhancement and **DO NOT** provide a fallback.
- **DO** respect user preferences: Some users prefer to have less motion on the web. Use the `prefers-reduced-motion` media query to disable or reduce your animations for these users.
- **DO** try to animate only performant CSS properties: For the smoothest animations, stick to animating properties that can be handled by the browser's compositor thread, such as `transform` and `opacity`. Animating other properties like `width` or `height` can lead to performance issues.
- **DO** use the correct declaration order: When using the `animation` shorthand property, declare `animation-timeline` *after* it to prevent the shorthand from resetting the timeline.
When using the `view()` function to create a scroll-driven animation:
- **OPTIONAL** be explicit about the axis to track: When not targeting the default `block` axis (such as in a horizontal scroller), be explicit about which axis to track with `view(block)` or `view(inline)`.
- When the animation is not applied to the tracked subject itself, use a named view timeline.
When using the `view-timeline` property to create a scroll-driven animation:
- **DO** use a CSS `<dashed-ident>` for the name.
- **OPTIONAL** be explicit about the axis to track: When not targeting the default `block` axis (such as in a horizontal scroller), be explicit about which axis to track with `view-timeline-axis`.
- **DO** make sure the scope of the lookup works: When the element that is declaring the `view-timeline` is not a flat tree ancestor of the animated element, hoist up the visibility of the `view-timeline`’s name by using `timeline-scope` on a shared ancestor.
Prefer a named `view-timeline` when multiple elements or children of the tracked subject need to animate.
## Browser support and fallback strategies
Scroll-driven animations has limited availability.
Supported by: Chrome 115 (Jul 2023), Edge 115 (Jul 2023), and Safari 26 (Sep 2025).
Unsupported in: Firefox.. Therefore, a fallback strategy is typically required.
For browsers that do not support scroll-driven animations, you can use a fallback to recreate the visual effects. The fallbacks are typically built with either a scroll listener (for ScrollTimeline effects) or the IntersectionObserver API (for ViewTimeline effects).
In browsers with built-in support for scroll-driven animations, ALWAYS use the native CSS implementation as those are more performant.
Note that not every effect can be recreated using the fallbacks approach.
For this use-case specifically, the following script applies the fallback for browsers that do not support scroll-driven animations. It uses an `IntersectionObserver` to track the visibility of the `.wrapper` element and updates the `transform` property of the layers based on the scroll position.
```html
<script>
if (!CSS.supports('(animation-timeline: view()) and (animation-range: entry)')) {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
// This matches the effect as defined in the CSS example above.
// Customize this further if needed.
entry.target.style.scale = 0.5 + entry.intersectionRatio * 0.5;
}
},
{
threshold: Array.from({ length: 101 }, (_, i) => i / 100),
}
);
document.querySelectorAll('.scroller > *').forEach((el) => {
observer.observe(el);
});
}
</script>
```
guides/ui-behaviors/scroll-snap-realtime-feedback.md
# Scroll Snap Real-Time Feedback
## Overview
Users expect immediate visual feedback when interacting with UI elements like carousels or galleries. Traditional scroll snap only provides feedback *after* the scroll gesture completes and the element settles. By using Scroll Snap Events, specifically `scrollsnapchanging`, you can provide real-time feedback during the scroll gesture, highlighting the pending snap target before the user releases their touch or mouse.
## Implementation
### 1. Listen for `scrollsnapchanging`
Attach an event listener for `scrollsnapchanging` to the scroll container. This event fires when the browser determines a new snap target is likely to be selected.
```javascript
const container = document.querySelector('#gallery');
const thumbnails = document.querySelectorAll('.thumbnail');
const items = document.querySelectorAll('.gallery-item');
container.addEventListener('scrollsnapchanging', (event) => {
// Highlight pending snap target during scroll for real-time feedback.
const pendingTarget = event.snapTargetInline;
const index = [...items].indexOf(pendingTarget);
if (index === -1 || !thumbnails[index]) return;
// Use lightweight class toggle to avoid layout thrashing during rapid events.
// Note: aria-current is NOT toggled here. It tracks the settled "current"
// item, which is updated in the scrollsnapchange handler below.
thumbnails.forEach((thumb) => thumb.classList.remove('pending'));
thumbnails[index].classList.add('pending');
});
```
This example uses `snapTargetInline` because the gallery scrolls horizontally. If your scroll container scrolls vertically, use `snapTargetBlock` instead.
### 2. Listen for `scrollsnapchange`
To finalize the state when the scroll gesture completes and the element actually snaps, listen for the `scrollsnapchange` event. This is required to establish the final active state.
```javascript
container.addEventListener('scrollsnapchange', (event) => {
// Promote pending state to active on scroll completion.
const snappedTarget = event.snapTargetInline;
const index = [...items].indexOf(snappedTarget);
if (index === -1 || !thumbnails[index]) return;
// Establish final active state and clean up pending.
thumbnails.forEach((thumb) => {
thumb.classList.remove('pending', 'active');
thumb.removeAttribute('aria-current');
});
thumbnails[index].classList.add('active');
thumbnails[index].setAttribute('aria-current', 'true');
});
```
### 3. Sync initial state
When the page loads, the scroll position might be restored by the browser (e.g., via history traversal or an anchor link). Neither `scrollsnapchange` nor `scroll` events will fire automatically. Run a one-off geometric check to sync the UI with the initial scroll position.
```javascript
// Note: For item.offsetLeft to be relative to the container,
// the container MUST be the offsetParent (e.g., `position: relative`).
const findClosestItemIndex = () => {
// Center-distance assumes scroll-snap-align: center on items.
// For start-aligned snap, compare scrollLeft to item.offsetLeft directly.
const containerCenter = container.scrollLeft + container.clientWidth / 2;
let closestIndex = 0;
let minDistance = Infinity;
items.forEach((item, index) => {
const itemCenter = item.offsetLeft + item.offsetWidth / 2;
const distance = Math.abs(containerCenter - itemCenter);
if (distance < minDistance) {
minDistance = distance;
closestIndex = index;
}
});
return closestIndex;
};
const initActiveItem = () => {
const closestIndex = findClosestItemIndex();
if (!thumbnails[closestIndex]) return;
thumbnails.forEach((thumb) => {
thumb.classList.remove('pending', 'active');
thumb.removeAttribute('aria-current');
});
thumbnails[closestIndex].classList.add('active');
thumbnails[closestIndex].setAttribute('aria-current', 'true');
};
if (document.readyState === 'complete') {
initActiveItem();
} else {
window.addEventListener('load', initActiveItem, { once: true });
}
```
### Fallback strategies
Scroll snap events has limited availability.
Supported by: Chrome 129 (Sep 2024) and Edge 129 (Sep 2024).
Unsupported in: Firefox and Safari.
Baseline status for Scroll snap: Widely available. It's been Baseline since 2020-01-15.
Supported by: Chrome 69 (Sep 2018), Edge 79 (Jan 2020), Firefox 68 (Jul 2019), and Safari 11 (Sep 2017).
For browsers that do not support `scrollsnapchanging`, the UI will not provide eager feedback during the scroll gesture by default, and the linked UI will desynchronize from the content.
**MANDATORY:** Provide a fallback for browsers without support, or the linked UI will desynchronize from the content.
**DO** simulate the real-time nature of `scrollsnapchanging` with a `scroll` event listener coupled with `requestAnimationFrame` and geometric distance calculations to determine the closest snap target while the user is actively scrolling.
```javascript
if ('onscrollsnapchanging' in Element.prototype) {
// Use native scroll snap events
} else {
// Fallback: use scroll + requestAnimationFrame for eager feedback
// (assumes the same container, thumbnails, items defined in step 1)
let scrollTimeout;
let rafId = null;
const promotePendingToActive = () => {
const closestIndex = findClosestItemIndex();
if (!thumbnails[closestIndex]) return;
thumbnails.forEach((thumb) => {
thumb.classList.remove('pending', 'active');
thumb.removeAttribute('aria-current');
});
thumbnails[closestIndex].classList.add('active');
thumbnails[closestIndex].setAttribute('aria-current', 'true');
};
container.addEventListener('scroll', () => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = null;
const closestIndex = findClosestItemIndex();
if (!thumbnails[closestIndex]) return;
// DO NOT forget to clean up stale pending classes
thumbnails.forEach((thumb) => thumb.classList.remove('pending'));
thumbnails[closestIndex].classList.add('pending');
});
// Debounce fallback for browsers that don't support scrollend
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(promotePendingToActive, 100);
}, { passive: true });
// Fallback: use Baseline `scrollend` event to promote pending to active cleanly where supported
container.addEventListener('scrollend', () => {
clearTimeout(scrollTimeout);
promotePendingToActive();
});
}
```
The geometric `scroll` + `requestAnimationFrame` fallback closely emulates the behavior of native snap prediction, including handling programmatic scrolling correctly. Because functions like `scrollIntoView` naturally fire `scroll` events during their execution, the UI will stay smoothly synchronized throughout the scroll animation without requiring additional custom logic.
guides/ui-behaviors/scroll-snap-state-sync.md
# Scroll Snap State Sync
Synchronizing UI state with a scrollable container's snap position traditionally required complex scroll event listeners, manual calculations of scroll offsets, and intersection observers. The `scrollsnapchange` event provides a native, efficient way to detect when a scroller has settled on a new snap target, making it useful for synchronizing sidebars or highlighting the active section in a table of contents.
## Implementation
### 1. Configure Scroll Snap in CSS
The container must have `scroll-snap-type` defined, and have children with `scroll-snap-align` for the browser to track snap targets. In a long article with a table of contents, you can use this to snap section headers to the top of the viewport.
```css
main {
/* Enable scroll snapping on the container */
scroll-snap-type: y proximity;
overflow-y: auto;
}
h2 {
/* Define how headers align when snapped */
scroll-snap-align: start;
}
```
### 2. Listen for Snap Changes
Use the `scrollsnapchange` event on the scroll container to react when the user finishes scrolling and the browser snaps to a new element. In our TOC demo, we use this to highlight the active link in the sidebar.
```html
<!-- MANDATORY: Wrap table of contents links inside a proper navigation landmark -->
<nav aria-label="Table of contents">
<ul>
<li><a href="#section-1" aria-current="location">Section 1</a></li>
<li><a href="#section-2">Section 2</a></li>
</ul>
</nav>
```
```javascript
const main = document.getElementById('main');
const links = document.querySelectorAll('nav a');
// The event fires when the scroller settles on a new snap target
main.addEventListener('scrollsnapchange', (event) => {
// Use snapTargetBlock for vertical or snapTargetInline for horizontal
const snappedHeader = event.snapTargetBlock;
if (snappedHeader) {
setSelectedParagraph(snappedHeader.id);
}
});
```
## Accessibility
Caution: While Scroll Snap Events make it possible to visually synchronize other content to the state of the scroller, it does not automatically expose that information programmatically. Relationships between elements, active states, and live content must be reflected in the Accessibility Tree.
For a table of contents, ensure the sidebar links use `aria-current="true"` or `aria-current="location"` when they are active.
In addition, be careful when using the `mandatory` value for `scroll-snap-type`, as it can cause content in-between snap-points to become inaccessible when longer than the screen.
## Fallback strategies
Scroll snap events has limited availability.
Supported by: Chrome 129 (Sep 2024) and Edge 129 (Sep 2024).
Unsupported in: Firefox and Safari.
If `scrollsnapchange` is not supported, use `IntersectionObserver` to detect which element is currently at the top of the scroller. Note this is different behavior than `scrollsnapchange`, as this will trigger while the scroll happens, rather than only when the scroll has settled.
```javascript
// Feature detect support for scroll snap events
if (!('onscrollsnapchange' in HTMLElement.prototype)) {
const observer = new IntersectionObserver(
() => {
// Each time the set of intersecting headers changes, find the top
// header that is visible.
const topEntry = [...headers].reduce((currentTop, header) => {
// Use the bottom to handle scrolling up, when the top is still offscreen
const {bottom} = header.getBoundingClientRect();
// Don't match if the header's bottom is above the scrollport
if (bottom < 0) return;
if (!currentTop) return header;
return bottom <
currentTop.getBoundingClientRect().bottom
? header
: currentTop;
}, undefined);
if (topEntry) setSelectedParagraph(topEntry.id);
},
{ root: main, threshold: 0.9 // Adjust based on your use case },
);
// Observe all snap targets (e.g., section headers)
document.querySelectorAll('h2').forEach(header => observer.observe(header));
}
```
guides/ui-behaviors/scroll-target-on-load.md
# Set a scroll target for the initial render
The CSS property `scroll-initial-target` offers a declarative, CSS-only way to bring a specific descendant element into the visible area of its scroll container as soon as that container is rendered. Previously, developers relied on JavaScript (`Element.scrollIntoView()`) or URL fragment identifiers (`#item-id`), both of which have limitations and are tricky to implement.
## How to Implement
To implement this successfully:
1. **Ensure a scroll container:** The target element must be inside a scroll container (an element with overflow that allows scrolling, such as `overflow: auto`). This can be any ancestor element, including the root `<html>` element.
2. **Target the Item:** Apply `scroll-initial-target: nearest` to the specific descendant element you want to bring into view.
## Example Code: Vertical Media Feed
In this example, a feed starts scrolled to a specific "featured" item rather than the very top of the list.
```css
/**
* TARGET: The item that should be visible on initial load.
*/
.item.target {
scroll-initial-target: nearest;
}
```
## Strategic Implementation & Best Practices
- **DO** use `scroll-initial-target` for "middle-start" experiences, such as a calendar starting on the current day or a gallery starting on a specific image.
- **DO NOT** confuse this with accessibility focus. This property only moves the **visual** viewport; it does not move the keyboard focus. You must manually manage `element.focus()` if the target is intended to be the starting point for keyboard users.
- **DO NOT** use this if you need a smooth "scrolling" animation on load; this property is discrete and sets the position instantly during the layout phase.
- **DO NOT** set `scroll-initial-target` on multiple elements within the same scrollable container. If multiple elements specify `scroll-initial-target: nearest`, the browser selects the one that appears first in the DOM tree order.
- **DO** provide dimensions for media. Since the scroll position is calculated during initial layout, ensure images or videos have `aspect-ratio` or fixed `height`/`width` to prevent the target from shifting after the media loads.
- **DO** account for the **Precedence Hierarchy**: A URL fragment (e.g., `example.com/#top`) and the container-level `scroll-start` property both take precedence over `scroll-initial-target`.
## Fallback Strategy
scroll-initial-target has limited availability.
Supported by: Chrome 133 (Feb 2025) and Edge 133 (Feb 2025).
Unsupported in: Firefox and Safari.
For browsers that do not yet support the API, use a JavaScript fallback. Use the `DOMContentLoaded` event to ensure the browser scrolls the element into view as soon as the HTML parsing completes, providing a faster experience than waiting for all images and resources to load. Alternatively, placing the script at the end of the `<body>` element is also acceptable and avoids the need for an event listener.
```javascript
/**
* Progressive Enhancement Fallback
*/
document.addEventListener("DOMContentLoaded", () => {
// Check for native CSS support
if (!CSS.supports("scroll-initial-target", "nearest")) {
const feedTarget = document.querySelector(".item.target");
if (feedTarget) {
// 'block: center' ensures the featured media is centered in view
feedTarget.scrollIntoView({ behavior: "instant", block: "center" });
}
}
});
```
guides/ui-behaviors/scrollytelling.md
# Scrollytelling
Scrollytelling is a popular technique used to create engaging and immersive web experiences. It involves animating elements on a page as the user scrolls, effectively telling a story or guiding the user through a narrative. With CSS Scroll-Driven Animations, you can create these effects directly in CSS, without needing to rely on JavaScript. The animations are controlled by the scroll position, not a time-based clock, which ensures they are always in sync with the user's scroll.
## How to implement
To create a scrollytelling experience, you need two sets of elements: one to track the scroll position and another to be animated.
First, define a named `view-timeline` on the elements you want to track. These will act as the drivers for your animations.
```css
#tracked {
section:nth-child(1){ view-timeline: --tl-1 block; }
section:nth-child(2){ view-timeline: --tl-2 block; }
section:nth-child(3){ view-timeline: --tl-3 block; }
section:nth-child(4){ view-timeline: --tl-4 block; }
section:nth-child(5){ view-timeline: --tl-5 block; }
}
```
Next, apply animations to the elements you want to animate and link them to the timelines you just created using the `animation-timeline` property.
```css
#animated {
section {
animation: animate-in auto linear both, animate-out auto linear forwards;
animation-range: entry 25% cover 50%, exit 50% exit 75%;
}
section:nth-child(1){ animation-timeline: --tl-1; }
section:nth-child(2){ animation-timeline: --tl-2; }
section:nth-child(3){ animation-timeline: --tl-3; }
section:nth-child(4){ animation-timeline: --tl-4; }
section:nth-child(5){ animation-timeline: --tl-5; }
}
```
For the `animation-timeline` to be able to reference the named timelines, they need to be in the same scope. You can use the `timeline-scope` property on a common ancestor to make the timelines available to all the elements that need them. The `:root` element is often a good choice for this.
```css
html {
timeline-scope: --tl-1, --tl-2, --tl-3, --tl-4, --tl-5;
}
```
Finally, you can use the `animation-range` property to specify the exact range of the timeline during which the animation should run. This gives you fine-grained control over when the animations are triggered and how they progress.
```css
#animated section {
animation-range: entry 25% cover 50%, exit 50% exit 75%;
}
```
## Example code
```css
html {
timeline-scope: --tl-1, --tl-2, --tl-3, --tl-4, --tl-5;
}
#tracked {
section:nth-child(1){ view-timeline: --tl-1 block; }
section:nth-child(2){ view-timeline: --tl-2 block; }
section:nth-child(3){ view-timeline: --tl-3 block; }
section:nth-child(4){ view-timeline: --tl-4 block; }
section:nth-child(5){ view-timeline: --tl-5 block; }
}
@keyframes animate-in {
from { scale: 0.5; opacity: 0; transform: rotateY(-180deg); }
to { transform: rotateY(0deg); }
}
@keyframes animate-out {
to { translate: 100% 0; opacity: 0; }
}
#animated {
section {
animation: animate-in auto linear both, animate-out auto linear forwards;
animation-range: entry 25% cover 50%, exit 50% exit 75%;
backface-visibility: hidden;
}
section:nth-child(1){ animation-timeline: --tl-1; }
section:nth-child(2){ animation-timeline: --tl-2; }
section:nth-child(3){ animation-timeline: --tl-3; }
section:nth-child(4){ animation-timeline: --tl-4; }
section:nth-child(5){ animation-timeline: --tl-5; }
}
/* MANDATORY Copy-Paste Safety: Disable continuous storytelling motion for sensitive users */
@media (prefers-reduced-motion: reduce) {
#animated section {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}
```
## Best Practices
When using scroll-driven animations, it's important to follow a few best practices to ensure a smooth and accessible experience:
- **DO** include feature detection: Not all browsers support scroll-driven animations. Use `@supports ((animation-timeline: scroll()) and (animation-range: 0% 100%))` to check for support and provide a fallback for browsers that don't support it.
- The `(animation-range: 0% 100%)` check **MUST** be included here, to filter out browsers with only partial support.
- **DO NOT** use the `scroll-timeline-polyfill` package for the fallback strategy as it is not feature complete and has a lot of known issues.
- If the animation is only considered to be decorative, opt for Progressive Enhancement and **DO NOT** provide a fallback.
- **DO** respect user preferences: Some users prefer to have less motion on the web. Use the `prefers-reduced-motion` media query to disable or reduce your animations for these users.
- **DO** try to animate only performant CSS properties: For the smoothest animations, stick to animating properties that can be handled by the browser's compositor thread, such as `transform` and `opacity`. Animating other properties like `width` or `height` can lead to performance issues.
- **DO** use the correct declaration order: When using the `animation` shorthand property, declare `animation-timeline` and `animation-range` *after* it to prevent the shorthand from resetting the timeline.
When using the `view-timeline` property to create a scroll-driven animation:
- **DO** use a CSS `<dashed-ident>` for the name.
- **OPTIONAL** be explicit about the axis to track: When not targeting the default `block` axis (such as in a horizontal scroller), be explicit about which axis to track with `view-timeline-axis`.
- **DO** make sure the scope of the lookup works: When the element that is declaring the `view-timeline` is not a flat tree ancestor of the animated element, hoist up the visibility of the `view-timeline`’s name by using `timeline-scope` on a shared ancestor.
## Fallback strategies
Scroll-driven animations has limited availability.
Supported by: Chrome 115 (Jul 2023), Edge 115 (Jul 2023), and Safari 26 (Sep 2025).
Unsupported in: Firefox.
For browsers that do not support scroll-driven animations, you can use a fallback to recreate the visual effects. The fallbacks are typically built with either a scroll listener (for ScrollTimeline effects) or the IntersectionObserver API (for ViewTimeline effects).
In browsers with built-in support for scroll-driven animations, ALWAYS use the native CSS implementation as those are more performant.
Note that not every effect can be recreated using the fallbacks approach.
For this use-case specifically, the following script applies the fallback for browsers that do not support scroll-driven animations. It uses an `IntersectionObserver` to track the visibility of each `#tracked section` element and updates the `transform` property of the corresponding `#animated section` accordingly.
```js
const animatedSections = document.querySelectorAll('#animated section');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const sectionIndex = Array.from(document.querySelectorAll('#tracked section')).indexOf(entry.target);
if (sectionIndex !== -1) {
const animatedSection = animatedSections[sectionIndex];
const ratio = entry.intersectionRatio;
// Animate-in
animatedSection.style.opacity = ratio;
animatedSection.style.transform = `scale(${0.5 + ratio * 0.5}) rotateY(${-180 + ratio * 180}deg)`;
// Animate-out
if (ratio < 0.5) {
animatedSection.style.translate = `${(0.5 - ratio) * 2 * 100}% 0`;
} else {
animatedSection.style.translate = '0 0';
}
}
});
}, { threshold: Array.from({length: 101}, (_, i) => i / 100) });
document.querySelectorAll('#tracked section').forEach(section => {
observer.observe(section);
});
```
And the accompanying CSS:
```css
#animated section {
opacity: 0;
transform: scale(0.5) rotateY(-180deg);
backface-visibility: hidden;
}
/* MANDATORY Copy-Paste Safety: Ensure content remains fully visible and legible for assistive technologies or users with motion sensitivities */
@media (prefers-reduced-motion: reduce) {
#animated section {
opacity: 1 !important;
transform: none !important;
translate: 0 0 !important;
}
}
```
This fallback provides a more accurate, scroll-driven animation for browsers that do not support the native CSS feature, ensuring a more consistent experience for all users. By using a series of thresholds for the `IntersectionObserver`, we can track the scroll position with more precision and create a smoother animation.guides/ui-behaviors/search-hidden-content.md
# Search hidden content
Web interfaces often hide content from view to improve the user experience, save screen space, or increase page performance. Traditional methods like `display: none` or `visibility: hidden` work to hide content visually, but they also make that content completely inaccessible to screen readers and browser features like "Find in page".
To hide content visually but still allow it to be searchable by users and enable it to be deep linked to via URL fragments and "Scroll to Text Fragment" links, you can use either the HTML `<details>` element or the `hidden="until-found"` attribute. The `<details>` element is generally recommended as it's simpler to implement and maintain, but there are some more complex cases where `<details>` is not sufficient and `hidden="until-found"` is required.
For example:
- If you want full control over the styling of the show/hide mechanism.
- If the UI controls to show/hide the content are in another part of the DOM.
- If you don't want to support hiding the content after it's shown.
## How to implement
The `<details>` element has searchable and accessible text by default, and no special implementation is required. Prefer using `<details>` over `hidden="until-found"` if possible.
If you need to use `hidden="until-found"` instead, follow these instructions:
1. **Apply the attribute:** Add the `hidden="until-found"` HTML attribute directly to the elements containing the content that should be hidden from view.
2. **Synchronize UI state:** If the interface has related states that depend on the content's visibility (e.g., updating ARIA attributes, toggling open/close CSS classes, or rotating accordion icons):
- You **MUST** add an event listener for the `beforematch` event.
- Register the `beforematch` event listener directly on the element carrying the `hidden="until-found"` attribute. Since the event bubbles, you may alternatively use event delegation by registering a single listener on a parent element (such as a tab container) to manage multiple hidden sections at once.
- Inside the event listener, execute the logic to synchronize related UI elements (such as closing other open tabs or changing the state of a toggle button).
## Example code
**Goal:** Render a hidden container where the content remains invisible to the user until they search for a word within that section.
### Using `<details>` element
```html
<details>
<summary>Click to expand</summary>
<p>This content is visually hidden.</p>
</details>
```
#### Mutually exclusive disclosures
When handling mutually exclusive content regions, like an exclusive accordion, use the native HTML `<details>` element with a shared `name` attribute.
```html
<div class="accordion-group">
<!-- The name attribute creates an exclusive disclosure group -->
<details class="disclosure" name="my-accordion">
<summary>Section 1</summary>
<p>Section 1 content</p>
</details>
<details class="disclosure" name="my-accordion" open>
<summary>Section 2</summary>
<p>Section 2 content</p>
</details>
<details class="disclosure" name="my-accordion">
<summary>Section 3</summary>
<p>Section 3 content</p>
</details>
</div>
```
### Using `hidden="until-found"` attribute
```html
<!-- The browser automatically removes hidden="until-found" upon a search match -->
<div class="hidden-container" hidden="until-found">
<p>This content is visually hidden.</p>
</div>
```
#### Custom mutually exclusive disclosures
When handling custom mutually exclusive regions controlled by external buttons, ensure only the matched panel remains visible. In the `beforematch` event handler, you MUST synchronize related ARIA states such as setting `aria-expanded="true"` on the controlling button.
```html
<div class="custom-accordion">
<div class="controls">
<button aria-expanded="true" aria-controls="panel-1" id="btn-1">Section 1</button>
<button aria-expanded="false" aria-controls="panel-2" id="btn-2">Section 2</button>
</div>
<div id="panel-1" class="panel">
<p>Section 1 content (visible)</p>
</div>
<div id="panel-2" class="panel" hidden="until-found">
<p>Section 2 content (hidden)</p>
</div>
</div>
```
```javascript
const accordion = document.querySelector('.custom-accordion');
accordion.addEventListener('beforematch', (e) => {
// Hide all panels and synchronize button states before the browser reveals the matched panel
accordion.querySelectorAll('.panel').forEach((panel) => {
if (panel !== e.target) {
panel.hidden = 'until-found';
}
});
accordion.querySelectorAll('button').forEach((btn) => {
const controls = btn.getAttribute('aria-controls');
btn.setAttribute('aria-expanded', controls === e.target.id ? 'true' : 'false');
});
});
```
## Best practices for `hidden="until-found"`
- **DO** apply borders, padding, and backgrounds to nested child wrappers rather than directly to the element with the `hidden="until-found"` attribute. This prevents unintended layout shifts or visual remnants while the element is hidden.
- **DO NOT** apply `display: none`, `visibility: hidden`, or any associated `display` or `visibility` CSS properties directly to elements with the `hidden="until-found"` attribute. This breaks the native functionality and permanently hides the content from the search index.
- **DO NOT** use `hidden="until-found"` for sensitive information, internal data tokens, or irrelevant data that should not be exposed via search.
- **DO NOT** use `hidden="until-found"` as a replacement for "screen reader only" (.sr-only) text.
## Browser support and fallback strategies
Baseline status for <details>: Widely available. It's been Baseline since 2020-01-15.
Supported by: Chrome 12 (Jun 2011), Edge 79 (Jan 2020), Firefox 49 (Sep 2016), and Safari 6 (Jul 2012).
hidden="until-found" has limited availability.
Supported by: Chrome 102 (May 2022), Edge 102 (May 2022), and Firefox 148 (Feb 2026).
Unsupported in: Safari.
**DO NOT** avoid `hidden="until-found"` because of missing browser support, as its accessibility benefits far outweigh the cost of implementing a fallback.
#### `hidden="until-found"` fallback
For standard UI elements like accordions or "Read more" sections, use JavaScript to feature-detect and show all content if the feature is unsupported.
```javascript
if (!('onbeforematch' in HTMLElement.prototype)) {
// Expand all hidden content for unsupported browsers
document.querySelectorAll('[hidden="until-found"]').forEach((el) => {
el.removeAttribute('hidden');
// MANDATORY: also update any aria references to this element.
});
}
```
For mutually exclusive UI paradigms (like custom exclusive panels where content shares the same visual region), the fallback should extract and display all content linearly below the main interactive area, using URL anchor fragments to allow users to navigate directly to the respective sections.
guides/ui-behaviors/swipe-to-remove.md
# Swipe to remove
Swipe-to-remove patterns are common in mobile applications but can be challenging to implement cleanly on the web. By using CSS Scroll Snap, you can create a smooth, native-feeling swipe interaction that hooks directly into the browser's scrolling engine. This ensures high performance and physics-based momentum without needing a complex JavaScript gesture library.
The same pattern works for any single-action swipe (remove, archive, mark as read, snooze). The action visuals change; the mechanics do not.
## How to implement
The component has two layers: a **list** (the `<ul>`) and the **items** inside it. Each item is structured as an outer `<li>`, an inner scroll **track** (the scroll container with the snap points), and a **content** element (the visible row). The action's revealed UI (trash icon, archive label, etc.) lives on either side of the content. The list owns shared wiring (lazy item setup, picking up newly added items); each item owns its own swipe detection.
### Step 1: Mark up the list with track and content
```html
<ul class="SwipeableList">
<li id="list-item-1" class="SwipeableList-item">
<div class="SwipeableList-track">
<div class="SwipeableList-content">Item One</div>
</div>
</li>
<li id="list-item-2" class="SwipeableList-item">
<div class="SwipeableList-track">
<div class="SwipeableList-content">Item Two</div>
</div>
</li>
<!-- ...more items... -->
</ul>
```
### Step 2: Configure the track as a horizontal snap container with three snap points
The track has three full-width columns: a left spacer (`::before`), the content, and a right spacer (`::after`). Snapping to a spacer means the content is fully off-screen, which is the rest position after a committed swipe.
The track configuration is gated behind an `.is-initialized` class on the list item. Before JS upgrades the row, the item just renders as plain content with no horizontal scroller. The class is added in Step 4 once the JavaScript that detects swipes has been wired up. This ensures the user cannot swipe before the functionality is ready.
```css
.SwipeableList {
list-style: none;
}
.SwipeableList-item {
/* Establishes a containing block for the absolutely positioned action
icons (Step 3) and clips overflow during the row's removal
animation (Step 4). */
contain: content;
}
/* The track only becomes a scroll snap container after JS upgrades
the row by adding `.is-initialized` (see Step 4). */
.SwipeableList-item.is-initialized .SwipeableList-track {
/* Three full-width columns: left spacer | content | right spacer.
Width is 100% of the track, so each column fills the viewport row. */
display: grid;
grid-template-columns: 100% 100% 100%;
/* Horizontal scroll only; vertical overflow is clipped so the
reveal stays inside the row. */
overflow: scroll clip;
/* Prevent the swipe from chaining into the page scroll or browser
back-gesture on iOS/Android. */
overscroll-behavior-x: none;
/* Hide the scrollbar; the gesture is the affordance. */
scrollbar-width: none;
/* `mandatory` ensures the track always rests on a snap point
(spacer or content), never partially scrolled. */
scroll-snap-type: x mandatory;
}
/* Spacers act as the left and right snap targets, AND carry the action's
reveal color. As the user swipes, the colored spacer slides into view,
which is what the user sees behind the content. */
.SwipeableList-item.is-initialized .SwipeableList-track::before,
.SwipeableList-item.is-initialized .SwipeableList-track::after {
content: '';
/* `scroll-snap-align` is required to make this a valid snap target,
but the specific value (`start`/`center`/`end`) doesn't matter here
because each snap point spans the full width of the scroll
container, so all alignments resolve to the same resting position. */
scroll-snap-align: start;
/* `hsl(0 65% 50%)` is an example value; pick whatever fits your
design (red here signals "delete"; use a different color for
archive, mark-as-read, etc.). */
background-color: hsl(0 65% 50%);
}
.SwipeableList-content {
/* The content sits above the action icons (Step 3), so it covers
them until the user swipes. */
position: relative;
z-index: 2;
/* Required to make the content a valid snap target (its resting
position). As with the spacers above, the specific value doesn't
matter because the snap points are full-width. */
scroll-snap-align: start;
/* The content must paint over the revealed spacer color. */
background: Canvas;
/* Row separator (example value; customize to taste). */
border-bottom: 1px solid #eee;
}
/* Gate `scroll-initial-target` behind `.is-initialized` so it only
applies once the track is actually a scroll container. Setting it on
the content before then would let the property walk up to the nearest
scrollable ancestor (typically the document) and shift the page's
initial scroll position to bring this row's content into view. With
the gate, the rule is only live when the row's own track can satisfy
it, so the initial scroll happens inside the track as intended. */
.SwipeableList-item.is-initialized .SwipeableList-content {
scroll-initial-target: nearest;
}
/* The track is the focusable scroll container, but its overflow is clipped
so a default focus ring on the track itself would be invisible. Project
the focus affordance onto the content element (which paints above the
track) using `:focus-visible` on the track. */
.SwipeableList-track:focus-visible .SwipeableList-content {
outline: auto;
outline-offset: -2px;
}
```
### Step 3: Add the action icons to the list item
The action color lives on the spacers (Step 2). The action **icon** lives on the list item itself, anchored to the row's left and right edges.
> **Note:** Throughout this guide, "left" refers to the *left side of the row* (which is revealed by swiping right), and "right" refers to the *right side of the row* (revealed by swiping left).
The placement, sizing, and motion below are a **starting suggestion**, not a requirement. Adjust the icon size, edge insets, threshold-pop scale, transition duration, and even the choice of pseudo-elements vs. real DOM nodes to match your design. The only mechanical requirement is that the icon sits behind the content (so the content can cover it pre-swipe) and inside the list item (so it doesn't scroll with the spacer). Everything else is taste.
```css
/* Action icons painted on the list item. They're absolutely positioned
inside the row (which is a containing block thanks to `contain: content`
on `.SwipeableList-item` from Step 2) and sit at z-index 1, so the
content element (z-index 2) covers them until the user swipes far
enough. */
.SwipeableList-item.is-initialized::before,
.SwipeableList-item.is-initialized::after {
/* Inline an SVG as the action icon. Replace this with whatever icon
fits the action (archive, checkmark, clock, etc.). The `fill='white'`
is baked into the SVG so it contrasts with the red spacer background;
adjust if your background color is light. */
--action-icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'><path d='M9 3v1H4v2h1v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6h1V4h-5V3H9zm0 5h2v9H9V8zm4 0h2v9h-2V8z'/></svg>");
content: '';
position: absolute;
z-index: 1;
/* The size (`width`) and insets (`left`/`right` below) are example
values, tune them to match your row height and visual weight.
The icon fills its box via `background-size: contain`, so changing
`width` resizes it. */
width: 1.5em; /* example value, adjust to taste */
aspect-ratio: 1;
top: 50%;
translate: 0 -50%;
/* Smooth transitions for the icon's visual states: the activate-point
pop (`scale`, see `.is-activating` below) and the removal fade
(`scale` + `opacity`, see `.is-removing` below). 0.2s is an example
duration. */
transition: scale 0.2s ease, opacity 0.2s ease;
background: var(--action-icon) center / contain no-repeat;
}
/* Inset from the row edge (example values; adjust to match your layout). */
.SwipeableList-item.is-initialized::before { left: 1.5em; }
.SwipeableList-item.is-initialized::after { right: 1.5em; }
/* Activating pop: scale the icon up when the user is past the visual
activate point, so the row's affordance feels reactive. Toggled by JS
in Step 4. */
.SwipeableList-item.is-activating::before,
.SwipeableList-item.is-activating::after {
scale: 1.333;
}
/* Removal affordance: fade and shrink the icons as the row collapses.
Driven by the `is-removing` class added in Step 4 at commit time;
the existing `transition` on the icons animates the change. */
.SwipeableList-item.is-removing::before,
.SwipeableList-item.is-removing::after {
scale: 0.5;
opacity: 0;
}
/* Only show the icon on the *leading* side of the swipe; hide the
trailing-side one. `data-swipe-direction` is set by JS in Step 4. */
.SwipeableList-item.is-activating[data-swipe-direction="left"]::after,
.SwipeableList-item.is-activating[data-swipe-direction="right"]::before {
visibility: hidden;
}
```
### Step 4: Detect the commit gesture with `IntersectionObserver`
Use `IntersectionObserver` rooted at the track, observing the content. As the user swipes, the content's intersection ratio with the track drops; we use **two thresholds**:
- `activateThreshold`: a high ratio (e.g., 0.8). When the visible portion of the content drops below this, the user is past the visual activate point. Toggle the icon-pop affordance.
- `commitThreshold`: a low ratio (e.g., 0.2). When the visible portion drops below this, the user has committed. Start the remove animation **immediately**, without waiting for the snap gesture to fully settle. The collapsing row blends into the user's continuing swipe momentum, which feels more responsive than waiting for the snap to land before reacting.
Two more concerns are handled here:
- **Lazy per-item setup**: a single outer `IntersectionObserver` rooted at the viewport drives setup and the start/stop of the inner swipe observers. Items only get wired up the first time they scroll into view, and items that scroll off-screen have their swipe observer paused. This keeps the active observer count bounded and avoids reading layout-dependent values (like `clientWidth`) before the item has been rendered.
- **Dynamic items**: real lists grow over time (initial render, infinite scroll, server push). A `MutationObserver` on the `<ul>` registers any newly added items with the outer observer.
```js
// Per-item handles. Populated when an item is first lazily wired up; read by
// the outer viewport observer to start/stop the inner observer as items enter
// and leave the viewport.
const swipeObservers = new WeakMap();
// Outer observer: drives the entire swipe lifecycle off viewport visibility.
// On first entry, lazily wires the item up (`setupItem` reads layout-dependent
// values like `clientWidth`, which return 0 until the item is rendered). After
// setup, starts the inner observer. On exit, stops the inner observer so
// offscreen items don't track scroll positions.
const viewportObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
const item = entry.target;
if (entry.isIntersecting) {
const handle = swipeObservers.get(item) ?? setupItem(item);
handle.observer.observe(handle.content);
} else {
const handle = swipeObservers.get(item);
if (handle) handle.observer.unobserve(handle.content);
}
}
});
function setupItem(item) {
const track = item.querySelector('.SwipeableList-track');
const content = track.querySelector('.SwipeableList-content');
// Upgrade the row into "swipeable" mode. This is the gate for all the CSS
// from Steps 2 and 3 (the track becomes a snap container, the action icons
// appear). Done *before* the inner observer is attached so the snap
// container exists by the time intersection callbacks can fire.
item.classList.add('is-initialized');
// Tunable thresholds. `activateThreshold` is the visual feedback point
// (icon pops). `commitThreshold` is the point of no return: once the
// content is past this point of being off-screen, we commit even if the
// user releases mid-gesture and the track snaps back. A low value (~0.2)
// commits before the snap settles, so the remove animation can start
// during the swipe.
const activateThreshold = 0.8;
const commitThreshold = 0.2;
// One inner observer per item, rooted at the track. Vertical scrolling of
// the outer list moves root and target together, so the callback only fires
// for the horizontal swipe.
const observer = new IntersectionObserver((entries, observer) => {
const entry = entries.at(-1);
const ratio = entry.intersectionRatio;
// Direction the user is swiping toward. A positive offset from the
// track's left edge means the content has been pulled right (left
// spacer revealed), so the leading icon is on the left.
const direction = (entry.boundingClientRect.x - entry.rootBounds.x) > 0
? 'left'
: 'right';
if (ratio < commitThreshold) {
// The IO entry's boundingClientRect is the last reliable measurement
// before the animation starts; reuse it for both the pre-collapse
// height and the slide-off translate distance.
removeItem(item, content, direction, entry);
viewportObserver.unobserve(item);
observer.disconnect();
return;
}
// Scale up the leading icon while the content is past the activate
// point; restore it at rest.
item.classList.toggle('is-activating', ratio < activateThreshold);
// Hold the previous direction at rest so the icon's exit animation
// finishes on the side the user was swiping toward.
if (entry.boundingClientRect.x !== entry.rootBounds.x) {
item.dataset.swipeDirection = direction;
}
}, {
root: track,
threshold: [commitThreshold, activateThreshold],
});
// Return the handle without starting observation; the outer viewport observer
// calls `observer.observe(content)` once the item is in view.
const handle = {observer, content};
swipeObservers.set(item, handle);
return handle;
}
async function removeItem(item, content, direction, entry) {
const opts = { duration: 300, easing: 'ease', fill: 'forwards' };
const rect = entry.boundingClientRect;
// Content's pixel offset from the track's left edge.
const x = rect.x - entry.rootBounds.x;
// Pixel distance the content needs to travel to be fully out of view.
const translate = direction === 'left'
? rect.width - x
: -(x + rect.width);
// Use a combination of CSS transitions (for declarative styles) and
// WAAPI animations (for computed values) to remove the element,
// then await the completion of all of them.
// Note: the content translate animation is important because the
// height-collapse animation can otherwise finish before the browser's
// smooth scroll-snap has scrolled the content fully off-screen.
item.classList.add('is-removing');
item.animate([{ height: `${rect.height}px` }, { height: '0px' }], opts);
content.animate([{ translate: `${translate}px` }], opts);
await Promise.allSettled(
item.getAnimations({ subtree: true }).map((a) => a.finished),
);
// Safari has a scroll-latching bug: removing the node while the swipe
// gesture's momentum is still resolving causes the next item (which
// slides up into this one's place) to inherit the scroll and
// immediately scroll itself off-screen. Detect Safari via
// `GestureEvent` (a Safari-only API) and defer the actual DOM removal
// until the gesture has fully settled. The 5s delay is conservative;
// anything longer than the momentum tail is fine. Mark the row inert
// so it can't be interacted with during the delay.
if (globalThis.GestureEvent) {
item.inert = true;
setTimeout(() => item.remove(), 5000);
} else {
item.remove();
}
}
function setupList(list) {
// Observe items already in the list.
for (const item of list.children) {
if (item.matches('.SwipeableList-item')) {
viewportObserver.observe(item);
}
}
// Pick up items added later (initial render after data loads, infinite
// scroll, server push). Removals don't need MutationObserver handling —
// the commit branch above already unobserves the item before removing it
// from the DOM.
new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE &&
node.matches('.SwipeableList-item')) {
viewportObserver.observe(node);
}
}
}
}).observe(list, { childList: true });
}
document.querySelectorAll('.SwipeableList').forEach(setupList);
```
### Step 5: Use the action and label that fits your use case
For variants other than removal, only the visuals and the body of the commit handler change:
- **Archive**: green/blue background, archive icon, move the item to an archive list rather than removing it.
- **Mark as read**: subdued background, checkmark icon, update item state and re-render (or just remove a `.unread` class).
- **Snooze**: blue background, clock icon, hide until a chosen time.
The scroll/snap/observation mechanics are unchanged.
#### Different actions per swipe direction
A single row can also expose **two different actions**: one for a left swipe and one for a right swipe (e.g., "archive" on right, "delete" on left), the way many native mail apps do it. The scroll, snap, and commit-detection mechanics don't change at all — the only thing that changes is what happens at commit time.
In Step 4, the commit branch always calls `removeItem(...)`. To support two actions, pick the handler based on `direction`:
```js
if (ratio < commitThreshold) {
const handler = direction === 'left' ? archiveItem : removeItem;
handler(item, content, direction, entry);
viewportObserver.unobserve(item);
observer.disconnect();
return;
}
```
A note on naming: `removeItem` is named for the destructive case, but the function it runs (collapse the row's height, slide the content off-screen, then drop the node) is really a generic "this row is done, animate it away" routine. It works just as well for archive, mark-as-read, or snooze — the row goes away from *this* list either way. If your handlers don't actually remove anything (e.g., both move the item elsewhere), rename it to something neutral like `dismissItem` so the code reads correctly.
To make the two actions visually distinct, hoist a color and icon for each direction onto the list item, then paint the track with a split gradient and the two pseudo-element icons from the same variables.
```css
.SwipeableList-item {
/* Action color + icon per swipe direction. `--left-*` is revealed
when the user swipes RIGHT (e.g., archive); `--right-*` is revealed
when the user swipes LEFT (e.g., delete). */
--left-action-color: hsl(140 50% 40%);
--left-action-icon: url("…archive svg…");
--right-action-color: hsl(0 65% 50%);
--right-action-icon: url("…trash svg…");
}
.SwipeableList-item.is-initialized .SwipeableList-track {
/* Split reveal: left half of the scrollable area gets the left-action
color, right half gets the right-action color. `background-attachment:
local` makes the gradient's positioning area the scrollable area
(3x the visible width), so the default size fills it and the 50% hard
stop lines up exactly with the midpoint of the resting content column.
The track's color also stays continuous behind the content as it
translates off, so the leading-direction color shows the whole way. */
background-image: linear-gradient(
to right,
var(--left-action-color) 50%,
var(--right-action-color) 50%
);
background-attachment: local;
}
/* Per-side icons read from the same variables. */
.SwipeableList-item.is-initialized::before { background-image: var(--left-action-icon); }
.SwipeableList-item.is-initialized::after { background-image: var(--right-action-icon); }
```
With this setup, the spacers no longer need their own background-color (the track's gradient handles the reveal), so you can drop the `background-color` rule on `.SwipeableList-track::before, ::after` from Step 2 if you're using this dual-action variant.
## Best practices and pitfalls
- **DO** use `mandatory` snap, not `proximity`. With `proximity`, the row can rest partially scrolled, leaving the action background half-visible.
- **DO** set `overscroll-behavior-x: none` on the track. Without it, an over-swipe can trigger the browser's back-navigation gesture on iOS/Android.
- **DO** commit at a threshold *before* the snap settles (e.g., `commitThreshold ≈ 0.2`) rather than waiting for the content to be fully off-screen. This lets the remove animation start during the gesture, which feels significantly more responsive than waiting for the snap to land.
- **DO** drive per-item setup from an outer viewport `IntersectionObserver` rather than wiring every item up at page load. This avoids reading layout-dependent values (`clientWidth`, etc.) before items have been rendered, and keeps the active observer count proportional to what the user can actually see.
- **DO** use a `MutationObserver` on the list when items are added dynamically (initial render after data loads, infinite scroll, server push). Without it, items appended after page load won't get wired up.
- **DO NOT** rely on `pointerdown`/`pointermove`/`pointerup` to drive a manual transform. You'll lose momentum, snap-back, keyboard accessibility, and reduced-motion handling that the browser gives you for free.
- **DO** confirm destructive actions when appropriate. For "remove", consider showing an undo toast after the swipe completes; the gesture is fast and easy to trigger by accident.
- **DO** ensure the scroll track is focusable, keyboard accessible, and that there is a visual focus affordance.
- **DO** provide accessible alternatives for any relevant actions triggered by the swipe (e.g., a visible button, context menu, or edit mode).
## Fallback strategies
Baseline status for Scroll snap: Widely available. It's been Baseline since 2020-01-15.
Supported by: Chrome 69 (Sep 2018), Edge 79 (Jan 2020), Firefox 68 (Jul 2019), and Safari 11 (Sep 2017).
Baseline status for Intersection observer: Widely available. It's been Baseline since 2019-03-25.
Supported by: Chrome 58 (Apr 2017), Edge 16 (Oct 2017), Firefox 55 (Aug 2017), Safari 12.1 (Mar 2019), and Safari iOS 12.2 (Mar 2019).
Baseline status for MutationObserver: Widely available. It's been Baseline since 2015-07-29.
Supported by: Chrome 26 (Mar 2013), Edge 12 (Jul 2015), Firefox 14 (Jul 2012), and Safari 7 (Oct 2013).
Baseline status for Resize observer: Widely available. It's been Baseline since 2020-07-28.
Supported by: Chrome 64 (Jan 2018), Edge 79 (Jan 2020), Firefox 69 (Sep 2019), Safari 13.1 (Mar 2020), and Safari iOS 13.4 (Mar 2020).
Baseline status for Web animations: Widely available. It's been Baseline since 2020-09-16.
Supported by: Chrome 84 (Jul 2020), Edge 84 (Jul 2020), Firefox 75 (Apr 2020), and Safari 14 (Sep 2020).
All newer features that are used are either not core to the experience or have robust fallbacks that can be reliably used now.
### Fallback for `overscroll-behavior`
overscroll-behavior has limited availability.
Supported by: Chrome 144 (Jan 2026), Edge 144 (Jan 2026), and Firefox 150 (Apr 2026).
Unsupported in: Safari.
No fallback is needed for this use case. Although `overscroll-behavior` has an interop issue that manifests on containers without scrollable overflow, the track here is always horizontally scrollable (three full-width columns inside a 100%-width container), so the property behaves consistently across browsers and the swipe gesture is reliably contained.
### Fallback for `scrollbar-width`
Baseline status for scrollbar-width: Newly available. It's been Baseline since 2024-12-11.
Supported by: Chrome 121 (Jan 2024), Edge 121 (Jan 2024), Firefox 64 (Dec 2018), and Safari 18.2 (Dec 2024).
Hidden scrollbars are a visual enhancement, not the mechanism that makes swipe-to-remove work. If your Baseline target does not include `scrollbar-width`, the row still scrolls, snaps, detects commit, and removes correctly; the unsupported experience may simply show a horizontal scrollbar. If your product requires hidden scrollbars in older WebKit-derived browsers, you can add a narrowly scoped `::-webkit-scrollbar { display: none; }` rule for the swipe track.
### Fallback for `scroll-initial-target`
scroll-initial-target has limited availability.
Supported by: Chrome 133 (Feb 2025) and Edge 133 (Feb 2025).
Unsupported in: Firefox and Safari.
If your Baseline target does not include `scroll-initial-target`, scroll the track to the content programmatically inside `setupItem`. Detect with `CSS.supports`:
```js
// Hoist the feature detect so the conditional `ResizeObserver` below can be
// skipped entirely when the property is supported.
const needsScrollWorkaround = !CSS.supports('scroll-initial-target', 'nearest');
function setupItem(item) {
// ...existing setup from Step 4...
if (needsScrollWorkaround) {
track.scrollLeft = track.clientWidth;
}
// ...attach the inner IntersectionObserver, etc.
}
```
**Call ordering matters.** The programmatic scroll MUST run:
1. **AFTER** `.is-initialized` is added to the list item (the class is what turns the track into a scroll container; setting `scrollLeft` on a non-scrollable element is a no-op).
2. **BEFORE** the inner `IntersectionObserver` from Step 4 is attached. Otherwise the initial programmatic scroll past the left spacer will be observed as a "swipe" and immediately fire the commit handler.
Driving setup from the outer viewport observer (Step 4) is what makes this reliable: `setupItem` runs after the item is rendered, so `track.clientWidth` returns a real value rather than `0`.
Some browsers (notably Safari) also reset the snap-container scroll position whenever the track resizes (URL-bar show/hide, viewport resize, container queries, etc.). Use a `ResizeObserver` on each track to re-apply the scroll. Gate it behind the same `CSS.supports` check — when `scroll-initial-target` is supported, the browser handles resize-time scroll restoration itself.
```js
const trackResizeObserver = needsScrollWorkaround
? new ResizeObserver((entries) => {
for (const entry of entries) {
entry.target.scrollLeft = entry.target.clientWidth;
}
})
: null;
function setupItem(item) {
// ...existing setup...
if (needsScrollWorkaround) {
track.scrollLeft = track.clientWidth;
trackResizeObserver.observe(track);
}
// ...attach the inner IntersectionObserver, etc.
}
```
Unobserve the track before the row's height animation runs in `removeItem`, otherwise the height change re-triggers the resize callback during removal. Add this alongside the existing `viewportObserver.unobserve(item)` in the commit branch:
```js
if (ratio < commitThreshold) {
removeItem(item, content, direction, entry);
viewportObserver.unobserve(item);
if (needsScrollWorkaround) trackResizeObserver.unobserve(track);
observer.disconnect();
return;
}
```
guides/ui-components/navigation-drawer.md
# Navigation Drawer
## Overview
A navigation drawer is a panel that slides in from the edge of the viewport over the page content, dimming everything behind it. It is opened from a trigger button and dismissed by swiping the panel off-screen, tapping the dimmed backdrop, or pressing Escape.
This guide implements the drawer as:
- A `popover="manual"` element promoted to the top layer so the panel and its `::backdrop` overlay every other element on the page, regardless of stacking context.
- A horizontally scrolling container with two CSS scroll-snap stops — one for "open", one for "closed" — so the swipe gesture is handled natively by the browser. This delivers momentum, velocity, and interruption tracking for free, with no JavaScript pointer-event code.
- A scroll-driven animation that ties the backdrop's opacity to the scroll position, so the dim fades in and out smoothly as the user drags the panel.
- An `IntersectionObserver` on the panel that detects when it has fully entered or fully left the viewport, and uses those moments to update focus, `aria-expanded`, and `inert`.
This approach is preferred over JavaScript-driven `transform` animations because the scroll mechanism gives the user direct control of the panel's position (their finger drives it, not a tween) and it much more closely matches the interaction patterns that users are accustomed to in native mobile apps.
## Implementation
### 1. Markup
The drawer is a single popover containing a horizontal scroller, which contains the visible "sheet". The trigger button lives in the page content.
```html
<!-- popover="manual" is REQUIRED. Do not use popover="auto" or "hint". -->
<div class="Drawer" id="drawer" popover="manual">
<div class="Drawer-scroller">
<nav class="Drawer-sheet" tabindex="-1">
<!-- tabindex="-1" makes the sheet programmatically focusable so we
can move focus into it when the drawer opens, without adding it
to the natural tab order. -->
<ul>
<li><a href="/page-1">Page 1</a></li>
<li><a href="/page-2">Page 2</a></li>
<!-- ... -->
</ul>
</nav>
</div>
</div>
<main>
<header>
<!-- aria-controls links the trigger to the drawer; aria-expanded
reflects the current state for assistive tech. -->
<button id="drawer-open"
aria-label="Menu"
aria-expanded="false"
aria-controls="drawer">
<!-- MANDATORY: Inline decorative SVGs MUST define aria-hidden="true" -->
<svg aria-hidden="true" viewBox="0 0 24 24">...</svg>
</button>
</header>
<!-- Page content. -->
</main>
```
### 2. Styles
#### Reset the popover and fill the viewport
The popover must cover the whole viewport so its `::backdrop` dims the entire page and the swipe surface extends edge-to-edge. The default user-agent popover styles (centered, auto-sized, bordered) get in the way and must be reset.
```css
.Drawer {
/* min() caps the sheet width on large screens but on a phone leaves
a 20% peek of page content visible, which is the affordance that
tells the user they can tap outside to dismiss. */
--drawer-width: min(20em, 80dvw);
/* Custom property driven by the scroll-driven animation below.
0 = drawer fully closed (transparent backdrop).
1 = drawer fully open (visible backdrop). */
--drawer-backdrop: 0;
/* Reset UA popover style that would constrain the element. */
width: auto;
height: auto;
background: transparent;
border: 0;
overflow: visible;
}
/* Style the popover's ::backdrop to achieve the overlay effect and
provide visual affordances indicating that the rest of the page is inert */
.Drawer::backdrop {
background: #000;
/* Use calc() to limit the opacity range so the content beneath is visible */
opacity: calc(var(--drawer-backdrop) / 2);
}
```
#### Build the swipe surface with scroll snap
The scroller is a horizontal grid wider than the viewport: column 1 holds the sheet (width `--drawer-width`), column 2 is an empty pseudo-element spacer the width of the viewport. Snapping between the two columns is what opens and closes the drawer.
```css
.Drawer-scroller {
position: relative;
display: grid;
/* Sheet on the left, full-viewport spacer on the right. The user
scrolls between the two snap stops to open and close. */
grid-template-columns: var(--drawer-width) 100%;
overflow-x: scroll;
/* Stop the swipe from chaining into the page's vertical scroll
when the user reaches either snap edge. */
overscroll-behavior: none;
scrollbar-width: none;
/* `mandatory` guarantees the drawer always settles fully open or
fully closed — never half-open after a partial swipe. */
scroll-snap-type: x mandatory;
}
/* Enable smooth scrolling natively, but only if the user has not
requested reduced motion. */
@media (prefers-reduced-motion: no-preference) {
.Drawer-scroller {
scroll-behavior: smooth;
}
}
/* The empty spacer that creates the "closed" snap stop. */
.Drawer-scroller::after {
content: '';
scroll-snap-align: end;
/* Open the popover already scrolled to this stop (drawer off-screen),
so the JS only needs to scroll to the open position to
animate it in. */
scroll-initial-target: nearest;
}
.Drawer-sheet {
display: grid;
grid-template-rows: auto 1fr;
/* Use `svh` (small viewport height) — not `vh` or `dvh` — so the
sheet height does not jump when the iOS Safari address bar
resizes mid-swipe. */
height: 100svh;
background: #333;
color: #fff;
overflow-y: auto;
scroll-snap-align: start;
scrollbar-width: none;
}
```
#### Tie the backdrop opacity to the scroll position
A scroll-driven animation maps `--drawer-backdrop` from 1 (open) to 0 (closed) across the scroller's range, so the backdrop fades in and out perfectly synced with the drag.
```css
/* MANDATORY: Wrap this entire block in @supports. Browsers that don't
support animation-timeline still parse the @keyframes and would
apply the animation's `0%` value (--drawer-backdrop: 1) at all
times, leaving the backdrop permanently opaque. The @supports gate
ensures the animation is only registered where it actually works. */
@supports (animation-timeline: scroll()) {
.Drawer {
/* timeline-scope lets .Drawer reference a scroll-timeline that
is defined on its descendant (the scroller). Without this, the
timeline name is not visible to the .Drawer element. */
timeline-scope: --drawer-fade;
animation: fade-drawer-backdrop linear both;
animation-timeline: --drawer-fade;
}
.Drawer-scroller {
/* The horizontal scroll position of this element drives the
timeline named `--drawer-fade`. */
scroll-timeline: --drawer-fade x;
}
/* @property is REQUIRED. Without registering --drawer-backdrop with
a `<number>` syntax, the browser treats it as a string and cannot
interpolate it — the backdrop would jump from 0 to 1 with no
fade. */
@property --drawer-backdrop {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
@keyframes fade-drawer-backdrop {
/* Scroll position 0 = drawer fully open = backdrop visible. */
0% { --drawer-backdrop: 1 }
/* Scroll position 100% = drawer fully closed = backdrop hidden. */
100% { --drawer-backdrop: 0 }
}
}
```
### 3. Open and close the drawer
Opening is two steps: promote the popover to the top layer, then scroll the sheet into view. Closing is one step: scroll back to the spacer; an observer (step 4) hides the popover once the sheet is fully off-screen.
```js
const drawer = document.getElementById('drawer');
const openBtn = document.getElementById('drawer-open');
const scroller = drawer.querySelector('.Drawer-scroller');
const sheet = drawer.querySelector('.Drawer-sheet');
function openDrawer() {
// Show the popover first so the element is in the top layer before
// we trigger any scrolling. `scroll-initial-target` (set on the
// ::after spacer) places the initial scroll position at the closed
// stop, so the drawer enters the top layer already off-screen.
drawer.showPopover();
// Scroll the sheet into view. The `behavior: 'auto'` option defers
// to the CSS `scroll-behavior` property, which will be smooth unless
// the user prefers reduced motion. Snap takes over at the end and
// locks the drawer fully open.
scroller.scrollTo({left: 0, behavior: 'auto'});
}
function closeDrawer() {
// Scroll back to the spacer. Do NOT call hidePopover() here —
// doing so would remove the element from the top layer mid-animation
// and the close animation would not be visible. The
// IntersectionObserver in step 4 hides the popover once the sheet
// has actually left the viewport.
scroller.scrollTo({left: scroller.offsetWidth, behavior: 'auto'});
}
```
### 4. Detect open and closed state
Use an `IntersectionObserver` on the sheet — not the scroll position — as the source of truth for the drawer's state. The observer fires regardless of how the sheet moved (user swipe, programmatic scroll, snap settle), so all dismissal paths converge in the same callback.
```js
function onDrawerOpened() {
// Mark the rest of the page inert so keyboard and screen-reader
// users cannot tab into content hidden behind the drawer.
document.querySelector('main').inert = true;
openBtn.setAttribute('aria-expanded', 'true');
// Move focus into the drawer for keyboard users.
sheet.focus();
}
function onDrawerClosed() {
// Hide the popover only after the close animation completes,
// so the slide-out is visible to the user.
drawer.hidePopover();
document.querySelector('main').inert = false;
openBtn.setAttribute('aria-expanded', 'false');
}
// Treat "any pixel of the sheet visible inside the popover root" as
// "open enough to count as not closed". This threshold is intentionally
// tiny so the closed callback only fires once the sheet is truly gone.
const visibleThreshold = 1 / window.innerWidth;
const observer = new IntersectionObserver(
(entries) => {
// During programmatic scrolling the observer can deliver multiple
// entries in one batch. Only the most recent describes the
// current state; earlier entries are intermediate positions.
const entry = entries.at(-1);
if (entry.intersectionRatio < visibleThreshold) onDrawerClosed();
if (entry.intersectionRatio === 1) onDrawerOpened();
},
// root: drawer makes the popover element the intersection root,
// so the ratio reflects the sheet's visibility within the popover
// (i.e. how much of it has been swiped on-screen).
{root: drawer, threshold: [visibleThreshold, 1]},
);
observer.observe(sheet);
```
### 5. Wire up the trigger and dismissal handlers
```js
// Open trigger.
openBtn.addEventListener('click', openDrawer);
// Light-dismiss: a tap on the dimmed area (anywhere inside the
// popover but outside the sheet) closes the drawer. We implement
// this manually because popover="manual" disables the browser's
// built-in light-dismiss (which would also fire mid-swipe — see step 1).
drawer.addEventListener('click', (event) => {
if (!sheet.contains(event.target)) closeDrawer();
});
// Escape key. Listen on document because focus may be inside the
// drawer when the user presses Escape.
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeDrawer();
});
```
### Fallback strategies
Baseline status for Scroll snap: Widely available. It's been Baseline since 2020-01-15.
Supported by: Chrome 69 (Sep 2018), Edge 79 (Jan 2020), Firefox 68 (Jul 2019), and Safari 11 (Sep 2017).
Baseline status for Intersection observer: Widely available. It's been Baseline since 2019-03-25.
Supported by: Chrome 58 (Apr 2017), Edge 16 (Oct 2017), Firefox 55 (Aug 2017), Safari 12.1 (Mar 2019), and Safari iOS 12.2 (Mar 2019).
Baseline status for inert: Widely available. It's been Baseline since 2023-04-11.
Supported by: Chrome 102 (May 2022), Edge 102 (May 2022), Firefox 112 (Apr 2023), and Safari 15.5 (May 2022).
The popover API, the scroll-driven animation that fades the backdrop, and `scroll-initial-target` are progressive enhancements with simple fallbacks that can be easily implemented if wide browser support is required.
#### Backdrop fade fallback (no `animation-timeline` support):
Scroll-driven animations has limited availability.
Supported by: Chrome 115 (Jul 2023), Edge 115 (Jul 2023), and Safari 26 (Sep 2025).
Unsupported in: Firefox.
Detect with `CSS.supports('animation-timeline: scroll()')` and write `--drawer-backdrop` from a `scroll` event listener if not supported. The CSS `@supports` block in step 2 ensures the keyframes never apply in unsupported browsers, so the JavaScript value is the only writer.
```js
if (!CSS.supports('animation-timeline: scroll()')) {
scroller.addEventListener('scroll', () => {
// Same mapping as the @keyframes: 0 scroll = 1 (open),
// sheet-width scroll = 0 (closed).
const ratio = 1 - scroller.scrollLeft / sheet.offsetWidth;
drawer.style.setProperty('--drawer-backdrop', ratio);
});
}
```
#### Initial scroll position fallback (no `scroll-initial-target` support):
scroll-initial-target has limited availability.
Supported by: Chrome 133 (Feb 2025) and Edge 133 (Feb 2025).
Unsupported in: Firefox and Safari.
Detect with `CSS.supports('scroll-initial-target', 'nearest')` and inside `openDrawer()`, jump-scroll the scroller to the closed position immediately after `showPopover()`. Without this, the drawer would appear instantly in the open position with no slide-in animation.
```js
async function openDrawer() {
drawer.showPopover();
if (!CSS.supports('scroll-initial-target', 'nearest')) {
// Jump-scroll to the closed stop so the scroll below
// animates the drawer in from off-screen.
scroller.scrollTo({left: scroller.offsetWidth, behavior: 'instant'});
// Wait two animation frames for the jump-scroll to commit.
// A single rAF is not enough — the second `scrollTo` would
// cancel the first before the browser has a chance to apply it.
await new Promise((r) =>
requestAnimationFrame(() => requestAnimationFrame(r))
);
}
scroller.scrollTo({left: 0, behavior: 'auto'});
}
```
#### `@property` fallback (no registered custom properties):
Baseline status for Registered custom properties: Newly available. It's been Baseline since 2024-07-09.
Supported by: Chrome 85 (Aug 2020), Edge 85 (Aug 2020), Firefox 128 (Jul 2024), and Safari 16.4 (Mar 2023).
`@property` is only needed because the scroll-driven animation interpolates `--drawer-backdrop` between keyframes — without registration, the property would be treated as a string and would jump between 0 and 1 with no fade. If the scroll-driven animation fallback above is in place, that JavaScript writes a fresh numeric string to `--drawer-backdrop` on every scroll frame and never interpolates, so no separate `@property` fallback is needed since all browsers that support scroll-driven animations also support `@property`.
#### Popover API fallback (no `popover` attribute support):
Baseline status for the api.HTMLElement.showPopover capability: Newly available. It's been Baseline since 2024-04-16.
Supported by: Chrome 114 (May 2023), Edge 114 (Jun 2023), Firefox 125 (Apr 2024), and Safari 17 (Sep 2023).
Because this component uses `popover="manual"` and implements dismissal entirely from JavaScript, it does not depend on the popover API's defining behaviors — light-dismiss, the `popovertarget` attribute, top-layer-managed Escape handling, or focus management. The only popover features it actually uses are top-layer promotion (via `showPopover()`) and the `::backdrop` pseudo-element.
If wider browser support is needed, do not branch on feature detection — simply do not use popover at all. Drop the `popover="manual"` attribute, replace top-layer promotion with `position: fixed` and a high `z-index`, replace `::backdrop` with a sibling element styled identically (using the same `--drawer-backdrop` custom property), and toggle visibility from a class instead of `showPopover()`/`hidePopover()`. The rest of the component (scroll snap, the scroll-driven backdrop animation, the `IntersectionObserver`, and the dismissal handlers) is unchanged.
guides/ui-components/persistent-app-tours.md
# Creating Persistent App Tours
Onboarding tours require overlays that persist while users interact with the highlighted features. Unlike auto popovers, manual popovers do not close when the user clicks elsewhere on the page. Combining `popover="manual"` with CSS Anchor Positioning allows you to create non-modal, tethered tour steps.
### Recommended Implementation
#### HTML
```html
<div id="feature-target">Highlight this feature</div>
<!-- MANDATORY: Enforce overlay dialog semantics and accessible name bindings -->
<div id="tour-step" popover="manual" role="dialog" aria-labelledby="tour-title">
<!-- Assume an <h1> precedes this element in the full document outline -->
<h2 id="tour-title">Step 1</h2>
<p>Learn how to use this feature.</p>
<button popovertarget="tour-step" popovertargetaction="hide">Got it</button>
</div>
```
#### CSS
```css
#feature-target {
anchor-name: --feature-target;
}
#tour-step {
popover: manual;
position-anchor: --feature-target;
position-area: right center;
inset: auto;
margin: 1rem;
padding: 1rem;
border: 1px solid blue;
border-radius: 0.5rem;
background: aliceblue;
}
```
#### JavaScript
```javascript
const tourStep = document.getElementById('tour-step');
tourStep.showPopover();
// MANDATORY: Programmatically route focus into the non-modal popover so keyboard/assistive technology users immediately perceive the new context
tourStep.querySelector('button').focus();
```
### Implementation Guidelines
* **MANDATORY:** Use `popover="manual"` to prevent the tour step from closing accidentally during user interaction.
* **MANDATORY:** Mark the container with `role="dialog"` and link its heading via `aria-labelledby`.
* **MANDATORY:** Shift programmatic focus inside the popover immediately after opening to prevent focus abandonment.
* **DO** use CSS Anchor Positioning to tether the tour step to the specific feature being explained.
* **DO** provide an explicit "Close" or "Next" button within the popover that uses `popovertargetaction="hide"`.
### Fallback strategies
### Fallbacks & browser support for Popover
Baseline status for Popover: Newly available. It's been Baseline since 2025-01-27.
Supported by: Chrome 116 (Aug 2023), Edge 116 (Aug 2023), Firefox 125 (Apr 2024), Safari 17 (Sep 2023), and Safari iOS 18.3 (Jan 2025).
The Popover API is mostly **progressive enhancement**, but its defining behaviors — top-layer promotion, light-dismiss, and `popovertarget` invocation — have no CSS-only equivalent. Older browsers need a polyfill, or a manual fallback if you would rather not ship one.
**Polyfill:** To support the `popover` attribute in older browsers, conditionally load [`@oddbird/popover-polyfill`](https://github.com/oddbird/popover-polyfill). **MANDATORY:** Feature detect by checking for the `popover` property on `HTMLElement.prototype`, and load the polyfill **only** when native support is missing — do NOT load it unconditionally.
With a bundler or import map:
```js
// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
import("@oddbird/popover-polyfill");
}
```
Without a bundler, import from a CDN inside a `<script type="module">`:
```html
<script type="module">
if (!("popover" in HTMLElement.prototype)) {
import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
}
</script>
```
**Styling caveat:** The polyfill cannot define the real `:popover-open` pseudo-class, so it applies a `.\:popover-open` class instead. **MANDATORY:** Combine the two with `:is()` or `:where()`, otherwise browsers that lack `:popover-open` discard the entire rule:
```css
[popover]:is(:popover-open, .\:popover-open) {
display: block;
}
```
Alternatively, for a legacy fallback without a polyfill, use `position: fixed` and manually calculate coordinates via `getBoundingClientRect()` or rely on default positioning with `inset: auto` if that's acceptable for the use case.
#### anchor-positioning
Anchor positioning is not natively supported by any major browser yet.
To support browsers without anchor positioning, you can choose between using a polyfill or a pure CSS fallback.
##### Option 1: Polyfill Fallback
The `@oddbird/css-anchor-positioning` polyfill can be used to emulate anchor positioning. It does not support implicit anchors, so you MUST add explicit anchor names to the trigger. Additionally, `position-area` is not supported on popovers by the polyfill, so you MUST use `anchor()` on the desired insets instead of `position-area`.
```html
<script type="module">
if (!CSS.supports('anchor-name: --foo')) {
await import("https://unpkg.com/@oddbird/css-anchor-positioning");
}
</script>
```
```css
#tour-step {
/* If using the anchor positioning polyfill with a popover, DO use `anchor()` functions instead of `position-area`. */
left: anchor(right);
top: anchor(top);
}
```
##### Option 2: Non-Polyfill CSS Fallback
If you prefer not to use a polyfill, you can default the tooltip to a fixed position at the bottom of the viewport using `@supports not`.
```css
@supports not (anchor-name: --foo) {
#tour-step {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
margin: 0;
border-radius: 0;
}
}
```guides/ui-components/persistent-toast-notifications.md
# Creating Toast Notifications
Toast notifications are transient status messages. Unlike menus, they should not close when a user interacts with other parts of the page. The popover="manual" state is ideal because it lacks "light-dismiss" behavior and allows multiple notifications to coexist.
### Implementation Guidelines
* **MANDATORY:** Use popover="manual" so the notification stays visible until explicitly closed or timed out by a script.
* **DO** use a container to manage the stacking of multiple toasts. Since popovers in the Top Layer ignore parent z-index, you must position them individually or within a common layout group.
* **DO** use sibling-index() to add margin between toast notifications so that items lower in the stack are visible.
* **DO** provide an explicit "Close" button within the toast using popovertargetaction="hide".
* **DO** use JavaScript for auto-dismissal timers (e.g., calling hidePopover() after 3000ms).
* **DO** utilize transition-behavior: allow-discrete to animate the entry and exit from the Top Layer.
### Fallback Strategies
### Fallbacks & browser support for Popover
Baseline status for Popover: Newly available. It's been Baseline since 2025-01-27.
Supported by: Chrome 116 (Aug 2023), Edge 116 (Aug 2023), Firefox 125 (Apr 2024), Safari 17 (Sep 2023), and Safari iOS 18.3 (Jan 2025).
The Popover API is mostly **progressive enhancement**, but its defining behaviors — top-layer promotion, light-dismiss, and `popovertarget` invocation — have no CSS-only equivalent. Older browsers need a polyfill, or a manual fallback if you would rather not ship one.
**Polyfill:** To support the `popover` attribute in older browsers, conditionally load [`@oddbird/popover-polyfill`](https://github.com/oddbird/popover-polyfill). **MANDATORY:** Feature detect by checking for the `popover` property on `HTMLElement.prototype`, and load the polyfill **only** when native support is missing — do NOT load it unconditionally.
With a bundler or import map:
```js
// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
import("@oddbird/popover-polyfill");
}
```
Without a bundler, import from a CDN inside a `<script type="module">`:
```html
<script type="module">
if (!("popover" in HTMLElement.prototype)) {
import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
}
</script>
```
**Styling caveat:** The polyfill cannot define the real `:popover-open` pseudo-class, so it applies a `.\:popover-open` class instead. **MANDATORY:** Combine the two with `:is()` or `:where()`, otherwise browsers that lack `:popover-open` discard the entire rule:
```css
[popover]:is(:popover-open, .\:popover-open) {
display: block;
}
```
Alternatively, for a legacy fallback without a polyfill, use `position: fixed` and manually calculate coordinates via `getBoundingClientRect()` or rely on default positioning with `inset: auto` if that's acceptable for the use case.
#### sibling-index()
* **Guidance:** If sibling-index() is not supported, use the `+` operator to add margin manually. I.e. `popover + popover { margin-top: 1rem }`
#### anchor-positioning
* **Guidance:** Use the [CSS Anchor Positioning Polyfill](https://github.com/oddbird/css-anchor-positioning). For a non-polyfill fallback, default the tooltip to a fixed position at the bottom of the viewport using `@supports not (anchor-name: --foo)`.
#### transition-behavior
* **Guidance:** If transition-behavior is not supported, use JavaScript to add animation via classes as the toast element transitions in and out.guides/ui-components/stack-drill-down.md
# Stack Drill Down
## Overview
A stack drill-down is a hierarchical navigation pattern, common in mobile apps, where activating a link pushes a new full-screen view on top of the previous one. The view's content is application-defined — a settings sub-page, a thread inside a feed, a folder inside a file browser, a detail page inside a gallery, etc. The user returns by swiping the current view off-screen to the right or by tapping a back button. Browser history stays in sync so the OS-level Back gesture, deep links, and forward/back navigation all work coherently.
This guide implements the stack as:
- A horizontal CSS scroll-snap container where each view is exactly one snap stop. Drilling down appends a new view and smooth-scrolls to it; the swipe-back gesture is handled natively by the browser, giving momentum, velocity, and interruption tracking for free with no pointer-event JavaScript.
- A `scrollsnapchange` event listener on the stack that fires when the snap target changes. This is used as the single source of truth for "the active view changed" — so swipe, click, programmatic scroll, and `popstate` paths all converge in one callback that updates `inert`, restores focus, prunes views the user swiped past, and reconciles browser history.
- A `pushState` / `popstate` integration so every drill-down adds a history entry, the OS-level Back gesture works, and deep links open directly into the right view.
- An (optional) scroll-driven `view()` animation on each view that produces a parallax + dim + shadow effect tied directly to the swipe gesture, so the visible motion is driven by the user's finger, not a tween.
This approach is preferred over JavaScript-driven `transform` animations because the snap mechanism gives the user direct gestural control of the panel's position (their finger drives it, not a tween) and matches the interaction patterns users expect from native mobile apps.
## Implementation
### 1. Markup
The static HTML is just an empty stack container; views are built in JavaScript and appended as the user navigates.
```html
<div class="Stack">
<!-- Intentionally empty. The initial view is appended by JavaScript
at init time (step 8). -->
</div>
```
Each view is a `.Stack-view` direct child of `.Stack` (the snap target) with a `.Stack-viewContent` wrapper inside it (where view content lives, and where the parallax transform applies — see step 2). At any moment the stack has one view per active history entry, left-to-right in drill-down order. After the user has drilled in two levels from the root the rendered DOM looks like this:
```html
<div class="Stack">
<!-- Root view. Has whatever content makes sense as the entry point
of this section of the app. No back button — there is nothing
behind the root in the stack. -->
<div class="Stack-view" inert>
<div class="Stack-viewContent">
<!-- Root content; includes <a href> links that drill in. -->
</div>
</div>
<!-- First-level drill-down view. The user got here by activating a
link in the root view. -->
<div class="Stack-view" inert>
<div class="Stack-viewContent">
<header>
<!-- DO include a back button. The swipe gesture only works on
touch — keyboard and pointer users need an explicit control. -->
<button class="back" aria-label="Back"></button>
<!-- Title / breadcrumb / etc. -->
</header>
<main>
<!-- View content; may include further drill-down <a href> links. -->
</main>
</div>
</div>
<!-- Second-level drill-down view. Currently visible — no `inert`
attribute. Same shape as the first-level view. -->
<div class="Stack-view">
<div class="Stack-viewContent">
<header>
<button class="back" aria-label="Back"></button>
<!-- ... -->
</header>
<main><!-- ... --></main>
</div>
</div>
</div>
```
Notes:
- All views except the currently-visible one carry the `inert` attribute. This is applied/removed automatically by the `scrollsnapchange` handler in step 7 — do not set it from your view builders.
- Views the user swipes back past are removed from the DOM (also by step 7) so the stack never grows beyond `currentDepth + 1` children. They are rebuilt on demand from their cached URL paths if forward navigation returns to them.
### 2. Styles
#### The stack scroller
The stack is a horizontal grid where each child view is exactly the width of the container, with CSS scroll-snap enforcing one-view-per-snap. This is what gives the swipe-back gesture its native feel.
```css
.Stack {
/* Use dvh so the height tracks the dynamic viewport on mobile, where
the address bar can show/hide. svh would clip during the address bar
animation; vh leaks under it. */
height: 100dvh;
/* Lay views out left-to-right, each one full-width, so horizontal
scrolling moves between them one at a time. */
display: grid;
grid-auto-flow: column;
grid-auto-columns: 100%;
grid-template-rows: 100%;
overflow-x: auto;
/* `mandatory` guarantees the stack always settles fully on a view —
never half-way between two. */
scroll-snap-type: x mandatory;
/* Prevent the swipe-back gesture from chaining into the browser's
own history-back gesture (iOS, some Android) or the page's vertical
scroll. The user is navigating the stack, not the page. */
overscroll-behavior-x: none;
}
/* Hide the visual scrollbar — the snap and the parallax are the
affordances; a horizontal scrollbar would look out of place. */
.Stack::-webkit-scrollbar {
display: none;
}
/* MANDATORY: Opt into smooth programmatic scrolling via CSS, gated on
prefers-reduced-motion. JS code calls scrollTo/scrollBy with
behavior: 'auto' which defers to this rule, so the OS-level reduced-
motion preference automatically downgrades to instant scrolling
without any per-call JS branching. */
@media (prefers-reduced-motion: no-preference) {
.Stack {
scroll-behavior: smooth;
}
}
.Stack-view {
scroll-snap-align: start;
/* `always` prevents the user from blowing through more than one view
per gesture, so depth changes always happen one step at a time. */
scroll-snap-stop: always;
}
/* MANDATORY: A separate inner element is required for the parallax
transform below. Applying transforms directly to the snap target
(.Stack-view) would feed back into the scroll container's snap
geometry and the scroller would jump mid-gesture. */
.Stack-viewContent {
width: 100%;
height: 100%;
background-color: #fff;
/* Each view scrolls its own content vertically, independent of the
stack's horizontal scroll. */
overflow-y: auto;
}
```
#### The "stack" effect (parallax / dim / shadow)
A scroll-driven `view(inline)` animation tracks each view's progress through the stack scroller and applies a parallax + dim to the exiting view, plus a shadow on incoming drill-down views so they read as "cards" stacking over the previous view.
```css
/* MANDATORY: Wrap the animation block in @supports. Browsers without
scroll-driven animations still parse the @keyframes and would
apply the `to` state as a static style, leaving every view
permanently transformed. The @supports gate confines the animation
to browsers where it actually animates. */
@supports (animation-timeline: view()) {
.Stack-viewContent {
/* view(inline) tracks this element's progress through its nearest
scrollable ancestor on the inline (x) axis. */
animation: parallax linear both;
animation-timeline: view(inline);
/* Only animate the EXIT phase — when this view is being covered
by a deeper one. During its own entry the view stays at rest,
so the fresh content is fully bright and in position throughout. */
animation-range: exit 0% exit 100%;
}
/* Drill-down views (everything except the root) also get a shadow on
their left edge during the transition so they feel like cards
stacking over the previous view. */
.Stack-view:not(:first-child) .Stack-viewContent {
animation: parallax linear both, shadow-fade linear both;
animation-timeline: view(inline), view(inline);
/* parallax: only exit (the view sliding back as a deeper one comes in).
shadow-fade: entry through exit (visible the whole time the view is
transitioning, not when it's at rest). */
animation-range: exit 0% exit 100%, entry 0% exit 100%;
}
@keyframes parallax {
/* translateX(75%) and brightness(0.8) are examples — adjust to taste. */
to {
transform: translateX(75%);
filter: brightness(0.8);
}
}
@keyframes shadow-fade {
/* Shadow ramps in during entry, holds across the middle of the
gesture, and ramps out during exit — so it's only visible while
the view is mid-transition, not when at rest. */
0%, 100% { box-shadow: 0 0 1.5rem #0000; }
25%, 75% { box-shadow: 0 0 1.5rem #0004; }
}
}
```
NOTE: This effect is popular in modern native stack applications and is a good starting point, but the exact visual effects can be customized to fit existing transition styles as needed.
### 3. Module state
The stack tracks four pieces of state in module scope:
```js
const stack = document.querySelector('.Stack');
// Reference to the root view DOM element. The Stack starts empty, so
// this is null until the root view is created — either at init (step 8,
// when the URL is '/') or later by synthesizeRootEntry() (when the user
// landed on a deep link and then navigates back). Held as a mutable
// reference because other code (the scrollsnapchange handler, init, etc.)
// uses identity comparisons against it.
let rootView = null;
// Tracks which element to restore focus to when the user swipes back
// into a previous view. Keyed by the view element itself so entries
// are garbage-collected automatically when the view is pruned.
const returnFocus = new WeakMap();
// Maps history depth -> {urlPath, view}. We MUST maintain this map
// ourselves because the History API does not expose state for entries
// other than the current one — so when a view is pruned on swipe-back,
// we still need to remember which URL it represented in case the user
// later forward-navigates back into it.
const entriesByDepth = new Map();
// Tracked manually because history.state on a popstate event tells us
// the destination depth but not where we came from. We need both to
// compute the direction (back vs forward) and the distance.
let currentDepth = 0;
```
Plus three application-specific helpers — the only places where your app's routing and view rendering plug in:
```js
// Resolve a URL path to the data your app needs to render the
// corresponding drill-down view, or return null for paths this section
// of the app does not handle (the root path '/', external links,
// unknown routes). resolveUrl() is for drill-down routes only — the
// root view is rendered separately by createRootView() below.
function resolveUrl(urlPath) {
// Replace with your routing logic. For example, match `/view/:id`
// and look the id up in your app state.
}
// Build the root (home) view of the stack. Application-specific content;
// preserve the .Stack-view / .Stack-viewContent wrapper structure (the
// inner element is required for the parallax — see step 2) and DO NOT
// render a back button — the root view has nothing behind it in the
// stack.
function createRootView() {
const view = document.createElement('div');
view.className = 'Stack-view';
view.innerHTML = `
<div class="Stack-viewContent">
<!-- Root content. Include <a href> elements pointing at URL paths
that resolveUrl() accepts, to enable drill-down from here. -->
</div>
`;
return view;
}
// Build a drill-down view DOM element from the resolved route data.
// Customize the inner content freely, but DO preserve the .Stack-view
// / .Stack-viewContent wrapper structure and DO include a back button
// (the swipe gesture only works on touch).
function createDrillDownView(routeData) {
const view = document.createElement('div');
view.className = 'Stack-view';
view.innerHTML = `
<div class="Stack-viewContent">
<header>
<button class="back" aria-label="Back"></button>
<!-- Title, breadcrumb, or other view chrome derived from
routeData. -->
</header>
<main>
<!-- View body, also from routeData. Include further <a href>
elements pointing at URL paths that resolveUrl() also
accepts, to enable additional drill-downs from this view. -->
</main>
</div>
`;
return view;
}
function getCurrentUrlPath() {
return location.pathname;
}
```
### 4. Drill down
A drill-down does four things in this order: push a history entry, build the new view, append it, smooth-scroll to it. The `scrollsnapchange` handler (step 7) picks up from there once the snap settles.
```js
function drillDown(urlPath) {
const routeData = resolveUrl(urlPath);
if (!routeData) return;
const newDepth = currentDepth + 1;
// Push BEFORE creating the view so the URL is correct if anything
// observing history (analytics, etc.) reads it during view creation.
history.pushState({depth: newDepth}, '', urlPath);
// pushState truncates forward entries in real browser history;
// mirror that truncation in our depth map so we don't hold references
// to views the user can no longer reach.
for (const d of entriesByDepth.keys()) {
if (d >= newDepth) entriesByDepth.delete(d);
}
currentDepth = newDepth;
const newView = createDrillDownView(routeData);
stack.appendChild(newView);
entriesByDepth.set(newDepth, {urlPath, view: newView});
// Scroll one viewport-width to the right. behavior: 'auto' defers to
// the CSS `scroll-behavior` set in step 2, which is smooth unless
// prefers-reduced-motion is set. The snap container locks onto the
// new view; the scrollsnapchange listener (step 7) fires when the
// snap settles.
stack.scrollBy({left: stack.clientWidth, behavior: 'auto'});
}
```
### 5. Click and back-button handling
Intercept link clicks inside the stack and convert them to drill-downs. Preserve modifier-key behavior so cmd/middle-click still opens the link in a new tab.
```js
stack.addEventListener('click', (e) => {
// Back button: defer to goBack() (defined below), which handles both
// the normal in-app case and the deep-link case.
if (e.target.closest('.back')) {
goBack();
return;
}
// Drill-down link.
const link = e.target.closest('a');
if (!link || !stack.contains(link)) return;
// Let the browser handle modified clicks so users can open links
// in new tabs / windows. e.button !== 0 filters out middle-clicks.
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
const urlPath = new URL(link.href).pathname;
const parentView = link.closest('.Stack-view');
// If the URL isn't handled by this section of the app (resolveUrl
// returns null), fall through so the browser navigates normally.
if (!resolveUrl(urlPath) || !parentView) return;
e.preventDefault();
// Record which link the user activated so focus can be restored to
// it when they swipe (or click) back into this view.
returnFocus.set(parentView, link);
drillDown(urlPath);
});
// Going back is usually just history.back(), but there's an important
// edge case: when the user lands directly on a deep-linked URL, there
// is no in-app history entry behind it. Calling history.back() in that
// situation would take them out of the app entirely. MANDATORY: detect
// this case and synthesize a root entry instead, so an in-app Back from
// a deep link lands on the root view and the platform Back from there
// returns the user to where they came from.
function goBack() {
const atDeepLinkRoot = currentDepth === 0
&& entriesByDepth.get(0)?.view !== rootView;
if (atDeepLinkRoot) {
synthesizeRootEntry();
} else {
// history.back() fires popstate, which routes through
// updateFromHistoryState (step 6) and scrolls the stack — the
// same path a swipe-back converges on.
history.back();
}
}
function synthesizeRootEntry() {
// Push a new history entry pointing at the root URL. This becomes the
// entry the user "came from"; the original deep-linked entry is now
// behind us, so platform Back from the root view will return there.
const newDepth = currentDepth + 1;
history.pushState({depth: newDepth}, '', '/');
// Create the root view if it doesn't exist yet (we landed on a deep
// link and never needed it before now), and insert it at the LEFT end
// of the stack. Adjust scrollLeft by one viewport width so the user's
// view doesn't visually jump — they should still be looking at the
// deep-linked view until the scroll animation below runs.
if (!rootView) {
rootView = createRootView();
stack.prepend(rootView);
stack.scrollLeft += stack.clientWidth;
}
entriesByDepth.set(newDepth, {urlPath: '/', view: rootView});
// Now scroll to the new entry (the root view). updateFromHistoryState
// smooth-scrolls one step left, the parallax plays, and
// scrollsnapchange fires when the root view settles.
updateFromHistoryState(history.state);
}
```
### 6. Sync from history (popstate)
`popstate` fires when the user uses the browser/OS back or forward button, or when JavaScript calls `history.back()` / `.go()`. This handler is the only path that scrolls the stack in response to a history change.
```js
window.addEventListener('popstate', (event) => {
updateFromHistoryState(event.state);
});
function updateFromHistoryState(state, behaviorOverride) {
const newDepth = state?.depth ?? 0;
const urlPath = getCurrentUrlPath();
// Ensure entriesByDepth has an entry for the destination depth.
// If the URL changed (e.g. forward-nav into a previously-pruned
// view), clear the cached view reference so the loop below rebuilds.
const entry = entriesByDepth.get(newDepth) ?? {view: null};
if (entry.urlPath !== urlPath) {
entry.urlPath = urlPath;
entry.view = urlPath === '/' ? rootView : null;
}
entriesByDepth.set(newDepth, entry);
// Rebuild any views between root and the destination that were
// pruned earlier (when the user swiped back past them). Without
// this, forward-navigating to a previously-pruned view would have
// no element to scroll to.
for (let d = 0; d <= newDepth; d++) {
const e = entriesByDepth.get(d);
if (!e || e.view) continue;
const routeData = resolveUrl(e.urlPath);
if (!routeData) continue;
const rebuilt = createDrillDownView(routeData);
stack.appendChild(rebuilt);
e.view = rebuilt;
}
currentDepth = newDepth;
const targetView = entriesByDepth.get(newDepth)?.view;
if (!targetView) return;
// Compare destination index against current scroll position so we
// can bail if they're already aligned. This is reached when the
// scrollsnapchange handler below calls history.go() to sync history
// after a swipe-back that already completed visually — there's
// nothing more to scroll.
const toIdx = [...stack.children].indexOf(targetView);
const fromIdx = Math.round(stack.scrollLeft / stack.clientWidth);
if (fromIdx === toIdx) return;
// Pick a scroll behavior:
// - multi-step jumps (e.g. history.go(-3)): 'instant' to skip
// intermediate snap points — otherwise smooth-scrolling would
// fire scrollsnapchange for each one and do N rounds of
// state-transition work for no reason.
// - rightward (forward) single-step: 'instant'. Browser-forward is
// rare on the web and is often spurious (e.g. iOS Safari treats
// edge swipes as forward navigation, even with overscroll-behavior
// set). An instant swap reads as "snap" rather than a misleading
// drilldown animation the user didn't ask for. The user-initiated
// drill-down path (drillDown, step 4) is unaffected — it calls
// scrollBy directly and never reaches this code.
// - leftward (back) single-step: 'auto' so the CSS `scroll-behavior`
// (smooth unless prefers-reduced-motion is set — see step 2)
// applies. Back is the common, expected case and benefits from
// the animation.
// NOTE: "forward" here means spatial direction (toIdx > fromIdx),
// NOT depth direction. synthesizeRootEntry (step 5) pushes a new
// depth but scrolls LEFT to the root view, which correctly reads as
// back-style (smooth).
const forward = toIdx > fromIdx;
const multiStep = Math.abs(toIdx - fromIdx) > 1;
const behavior = behaviorOverride ?? (forward || multiStep ? 'instant' : 'auto');
stack.scrollTo({left: toIdx * stack.clientWidth, behavior});
}
```
### 7. `scrollsnapchange`: the single source of truth
After every snap commit — whether triggered by a swipe, a click, or a programmatic scroll — the browser fires a `scrollsnapchange` event on the scroll container, with the newly snapped element exposed as `event.snapTargetInline` (for horizontal snapping). Putting all state transitions inside this one handler is what keeps the swipe path, the click path, and the `popstate` path coherent.
The handler is extracted into a standalone `onActiveViewChanged` function so the fallback (see "Fallback strategies" below) can reuse it without duplicating the logic.
```js
function onActiveViewChanged(currentView) {
// Walk the stack in DOM order to update each view's role:
// - Views at or before currentView stay in the DOM but get
// `inert` (except currentView) so focus, pointer events, and
// AT navigation cannot leak into views hidden behind the
// parallax.
// - Views after currentView are unreachable (the user swiped
// back past them) so we drop them from the DOM to free memory.
// Their urlPath stays in entriesByDepth so a later forward
// navigation can rebuild the view from scratch.
let seenCurrent = false;
for (const view of [...stack.children]) {
if (seenCurrent) {
for (const e of entriesByDepth.values()) {
if (e.view === view) e.view = null;
}
view.remove();
} else {
// MANDATORY: inert non-current views. Without this, tabbing
// and screen-reader navigation can reach content hidden behind
// the parallax — a severe accessibility failure that's
// invisible to sighted users.
view.toggleAttribute('inert', view !== currentView);
if (view === currentView) seenCurrent = true;
}
}
// If the visible view's depth doesn't match `currentDepth`, the
// user got here by swiping (not clicking) — sync history so the
// browser back/forward buttons stay coherent with what's on screen.
let currentViewDepth;
for (const [d, e] of entriesByDepth) {
if (e.view === currentView) currentViewDepth = d;
}
if (currentViewDepth !== undefined && currentViewDepth !== currentDepth) {
// history.go fires popstate, which re-enters updateFromHistoryState.
// That call's fromIdx === toIdx check bails out without scrolling.
history.go(currentViewDepth - currentDepth);
}
// Restore focus on the now-active view:
// - If we recorded which link the user activated to drill out
// of this view, return focus there so a swipe-back lands them
// exactly where they left off.
// - Otherwise (a freshly-pushed drill-down view), move focus to
// the back button so keyboard users have an obvious next action.
// - preventScroll is REQUIRED: without it, .focus() scrolls the
// snap container to bring the focused element into view, which
// fights the snap and can land the user mid-snap.
const stored = returnFocus.get(currentView);
if (stored) {
stored.focus({preventScroll: true});
returnFocus.delete(currentView);
} else if (currentView !== rootView) {
currentView.querySelector('.back')?.focus({preventScroll: true});
}
}
stack.addEventListener('scrollsnapchange', (event) => {
// snapTargetInline is the element that was just snapped to on the
// inline (horizontal) axis. For this stack — where each view is one
// horizontal snap stop — that's the new active view.
onActiveViewChanged(event.snapTargetInline);
});
```
### 8. Initialization (including deep links)
When the page loads, the URL may already point at a deep view (a shared link, a bookmark, a refresh on a deep page). Build whichever initial view matches the URL — root or deep-linked, but never both — append it to the empty stack, seed the depth-0 history entry, and run an initial scroll pass with `behavior: 'instant'` so the parallax doesn't animate on first paint.
```js
const initialUrlPath = getCurrentUrlPath();
const initialRouteData = resolveUrl(initialUrlPath);
// Build the initial view: a drill-down view if the URL maps to one,
// otherwise the root view. Whichever it is, that's the only view in
// the stack right now — the other will be created lazily by
// synthesizeRootEntry (step 5) or drillDown (step 4) if the user
// navigates to it.
let initialView;
if (initialRouteData) {
initialView = createDrillDownView(initialRouteData);
} else {
rootView = createRootView();
initialView = rootView;
}
stack.appendChild(initialView);
entriesByDepth.set(0, {urlPath: initialUrlPath, view: initialView});
// replaceState attaches a `depth` to the entry the user landed on, so
// any subsequent pushState / popstate has a base depth to count from.
history.replaceState({depth: 0}, '');
updateFromHistoryState(history.state, 'instant');
```
### Best practices
- **DO** use the `scrollsnapchange` event (with an `IntersectionObserver` fallback — see "Fallback strategies") as the source of truth for "the active view changed", not scroll-event coordinates. Snap commit is the only event that fires consistently across swipe, click, programmatic scroll, and `popstate` paths.
- **DO** apply transforms to a child of the snap target, never to the snap target itself. A transform on the snap target feeds back into the scroll container's snap geometry and the scroller will glitch mid-gesture.
- **DO** apply `inert` to every view except the currently visible one. Without this, focus and screen-reader navigation leak into views hidden behind the parallax — invisible to sighted users but a severe accessibility failure.
- **DO** push a history entry on every drill-down and handle `popstate` so the OS-level Back gesture and the browser back/forward buttons work. This is what makes the pattern feel like a native app.
- **DO** reconcile history from the active-view-changed handler when a swipe-back lands on a view whose depth doesn't match `currentDepth`. Without this, a subsequent OS Back returns the user somewhere unexpected because the browser's history cursor is out of sync with what's on screen.
- **DO** prune views the user swiped past from the DOM. A long drill-down session can otherwise accumulate dozens of detached subtrees. The cached URL path in `entriesByDepth` is enough to rebuild any view if forward navigation returns to it.
- **DO** call `.focus({preventScroll: true})` when restoring focus inside the stack. The default `preventScroll: false` makes the browser scroll the focus target into view, which fights the snap container and can land the user mid-snap.
- **DO** preserve cmd/ctrl/middle-click on internal links so URLs remain shareable and openable in a new tab.
- **DO** use `behavior: 'instant'` for multi-step history jumps AND for spatial-forward popstate transitions (`toIdx > fromIdx`). Multi-step jumps would otherwise fire `scrollsnapchange` at every intermediate snap and do N rounds of inert/focus/history work. Forward popstates are often spurious — iOS Safari treats edge swipes as browser forward even with `overscroll-behavior-x: none` set, and an instant swap is much less misleading than animating a "drilldown" the user didn't initiate. The user-initiated drill-down path (`drillDown`) is unaffected because it scrolls directly, not via `popstate`.
- **DO** respect `prefers-reduced-motion`: declare `scroll-behavior: smooth` only inside `@media (prefers-reduced-motion: no-preference)` and call `scrollTo` / `scrollBy` with `behavior: 'auto'` (not `'smooth'`) so the OS-level preference takes effect without per-call JS branching. Hard-coding `behavior: 'smooth'` bypasses the user's setting.
- **DO** render real `<a href>` elements as drill-down triggers, not `<button onclick>` or `<div>`. Real anchors get URL preview on hover, shareability, middle-click, screen-reader role, and SEO for free.
- **DO** include an explicit back button in every drill-down view. The swipe gesture only works on touch — keyboard, pointer, and desktop users need a visible affordance.
- **DO NOT** call `history.pushState` from the `popstate` handler — that pushes *new* entries while the user is trying to go back and breaks the browser back button.
- **DO NOT** drive the parallax with a `scroll` event listener when scroll-driven animations are available. The CSS path runs on the compositor; a JS scroll listener runs on the main thread and will visibly drop frames during the gesture.
- **DO NOT** mutate views you removed from the DOM after a swipe-back. Treat `entriesByDepth` as the canonical record: a pruned entry has `view: null` and is rebuilt on demand in `updateFromHistoryState`.
### Fallback strategies
Baseline status for Scroll snap: Widely available. It's been Baseline since 2020-01-15.
Supported by: Chrome 69 (Sep 2018), Edge 79 (Jan 2020), Firefox 68 (Jul 2019), and Safari 11 (Sep 2017).
Baseline status for Intersection observer: Widely available. It's been Baseline since 2019-03-25.
Supported by: Chrome 58 (Apr 2017), Edge 16 (Oct 2017), Firefox 55 (Aug 2017), Safari 12.1 (Mar 2019), and Safari iOS 12.2 (Mar 2019).
Baseline status for inert: Widely available. It's been Baseline since 2023-04-11.
Supported by: Chrome 102 (May 2022), Edge 102 (May 2022), Firefox 112 (Apr 2023), and Safari 15.5 (May 2022).
The features that may require fallbacks are scroll-snap-events and scroll-driven-animations, both of which have robust fallback or progressive enhancement stories, and are safe to use for this use case:
#### Scroll snap events
Scroll snap events has limited availability.
Supported by: Chrome 129 (Sep 2024) and Edge 129 (Sep 2024).
Unsupported in: Firefox and Safari.
The `scrollsnapchange` event is the cleanest way to detect "the active view changed" — one listener on the stack, fired exactly once per snap commit. In browsers without it, the same effect can be polyfilled with an `IntersectionObserver` watching each view for full visibility inside the stack. The fallback dispatches into the same `onActiveViewChanged` function the primary path uses, so all the state-transition logic stays in one place.
```js
// MANDATORY when supporting browsers that haven't shipped scroll-snap-events
// yet. Check `HTMLElement.prototype` (not `window` or `document`) — the
// event handler IDL attribute is added to the prototype when the feature
// is supported, regardless of whether any element has the handler set.
if (!('onscrollsnapchange' in HTMLElement.prototype)) {
const viewObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
// threshold:1 only fires for fully-visible entries, but the
// observer also emits a "leaving" entry per view that drops below
// ratio 1. Filter to the entering side, which is the snap-commit
// moment we're trying to detect.
if (entry.intersectionRatio === 1) {
onActiveViewChanged(entry.target);
}
}
}, {root: stack, threshold: 1});
// Auto-observe every .Stack-view as it's added to the stack, and stop
// observing as it's removed. Using a MutationObserver lets the primary
// code (drillDown, updateFromHistoryState, synthesizeRootEntry, init)
// stay free of fallback wiring.
new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.classList?.contains('Stack-view')) viewObserver.observe(node);
}
for (const node of m.removedNodes) {
if (node.classList?.contains('Stack-view')) viewObserver.unobserve(node);
}
}
}).observe(stack, {childList: true});
// Catch up to any views already in the stack at the time this code
// runs (typically the initial view appended in step 8).
for (const view of stack.children) viewObserver.observe(view);
}
```
#### Scroll-driven animations
Scroll-driven animations has limited availability.
Supported by: Chrome 115 (Jul 2023), Edge 115 (Jul 2023), and Safari 26 (Sep 2025).
Unsupported in: Firefox.
The scroll-driven parallax / dim / shadow effect is a progressive enhancement on top of the navigation core. The CSS `@supports (animation-timeline: view())` gate (shown in step 2) confines the animation to supporting browsers; everywhere else the views simply cut between snap stops with no transition. The component is fully functional without the parallax — snap, history sync, focus management, and `inert` all still work.
If a parallax fallback is required for older baseline targets, attach a `scroll` listener to the stack and write a CSS custom property describing each view's progress through the scrollport, then drive `transform` and `filter` from that property:
```js
if (!CSS.supports('animation-timeline: view()')) {
stack.addEventListener('scroll', () => {
const viewWidth = stack.clientWidth;
for (const view of stack.children) {
// Progress: 0 when this view is centered, 1 when it has fully
// exited to the left. Matches the @keyframes mapping above.
const offsetLeft = view.offsetLeft - stack.scrollLeft;
const progress = Math.min(1, Math.max(0, -offsetLeft / viewWidth));
const content = view.querySelector('.Stack-viewContent');
content.style.transform = `translateX(${progress * 75}%)`;
content.style.filter = `brightness(${1 - progress * 0.2})`;
}
});
}
```
guides/visual-design/adapt-scrollbar-to-contrast-preferences.md
# Adapt scrollbar to high-contrast preferences
Users who enable high-contrast modes in their operating system or browser expect UI elements (like scrollbars) to be extremely legible, often relying on stark foreground-background separation rather than subtle grays or theme colors.
This guide provides optional instructions on how to use the `@media (prefers-contrast: more)` CSS media feature to enforce high-contrast scrollbar styling.
## Enhance Legibility
When customizing scrollbars with `scrollbar-color` or custom variables, you can provide an explicit override for high-contrast modes. This is especially helpful if your primary application theme uses low-contrast scrollbars for aesthetic reasons.
OPTIONAL: Use a `@media (prefers-contrast: more)` block to define dark, distinct colors for the thumb and track.
```css
/* Define default standard colors as variables */
.scroller {
--scrollbar-thumb: #bbb;
--scrollbar-track: #f1f1f1;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
scrollbar-width: thin;
scrollbar-gutter: stable;
}
/* OPTIONAL: Provide clear, high-contrast overrides */
@media (prefers-contrast: more) {
.scroller {
/* Use extremely distinct colors like solid black against white */
--scrollbar-thumb: #000000;
--scrollbar-track: #ffffff;
}
}
```
### Issues to be aware of when using scrollbar-color
- Do NOT animate or transition `scrollbar-color`. A [WebKit bug](https://bugs.webkit.org/show_bug.cgi?id=311752) causes the scrollbar to flicker every time `scrollbar-color` changes.
- On macOS, `scrollbar-color` (standard) and `::-webkit-scrollbar` (legacy) properties are ignored by default because macOS uses native "overlay" scrollbars. You MUST pair custom colors with `scrollbar-width` (e.g., `thin` or `auto`) to force macOS to render them.
- Even with `scrollbar-width` applied, macOS overlay scrollbars render the track (gutter) as transparent by default. If the design requires a visible track background color on MacOS, you MUST apply `scrollbar-gutter: stable;` to the scrollable container, but note that it only appears after the user hovers over the scrollbar.
- Even with `scrollbar-gutter: stable` the track may be transparent on MacOS. The thumb should not depend on the track color to be visible.
## Fallbacks & Browser Support
### Fallbacks & browser support for scrollbar-color
Baseline status for scrollbar-color: Newly available. It's been Baseline since 2025-12-12.
Supported by: Chrome 121 (Jan 2024), Edge 121 (Jan 2024), Firefox 64 (Dec 2018), and Safari 26.2 (Dec 2025).
This feature is progressive enhancement and does not always require fallbacks.
If the styling is important and the user's Baseline target is "Baseline Widely Available" or earlier, you SHOULD include the non-standard `::-webkit-scrollbar` pseudo-elements as fallbacks.
Wrap legacy fallbacks in an `@supports not (scrollbar-color: auto)` block to prevent conflicts between standard properties and legacy WebKit selectors in browsers that support both natively.
If you are using custom properties to define colors, these will cascade to the legacy WebKit selectors automatically. You do NOT need to duplicate them.
```css
/* Legacy fallback for WebKit/Blink browsers */
@supports not (scrollbar-color: auto) {
.scroller::-webkit-scrollbar {
/* Must define base size in WebKit for custom colors to be visual */
width: 12px;
height: 12px;
}
.scroller::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
}
.scroller::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
}
```
guides/visual-design/apply-webgl-shaders.md
# Apply WebGL shaders to HTML content
WebGL shaders provide powerful GPU-accelerated visual effects, enabling advanced capabilities like dynamic ripple distortions, lighting models, color grading, and custom vertex transformations. The HTML-in-Canvas API allows developers to apply WebGL textures to HTML content. This enables applying high-performance fragment and vertex shaders natively to fully interactive UI components, such as buttons, input fields, and rich text, while retaining native accessibility, text selection, and DOM event handling.
## How to implement
1. Check if HTML-in-Canvas is supported in the browser:
```
if ('requestPaint' in HTMLCanvasElement.prototype) {
// Use HTML in Canvas API
} else {
// Use fallback strategy
}
```
2. Add the `layoutsubtree` attribute to the `<canvas>` HTML element.
3. Place your HTML content inside the `<canvas>` element with the `layoutsubtree` attribute.
```html
<canvas id="canvas" layoutsubtree>
<div id="html-content"></div>
</canvas>
```
4. Scale your canvas grid to match the device scale factor to prevent blurriness:
```js
const observer = new ResizeObserver(([entry]) => {
const dpc = entry.devicePixelContentBoxSize;
canvas.width = dpc
? dpc[0].inlineSize
: Math.round(entry.contentRect.width * window.devicePixelRatio);
canvas.height = dpc
? dpc[0].blockSize
: Math.round(entry.contentRect.height * window.devicePixelRatio);
});
const supportsDevicePixelContentBox =
typeof ResizeObserverEntry !== "undefined" &&
"devicePixelContentBoxSize" in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox
? { box: "device-pixel-content-box" }
: {};
observer.observe(canvas, options);
```
5. Render the HTML content to the canvas inside a `canvas.onpaint` event handler using the `texElementImage2D` method:
```js
canvas.onpaint = () => {
if (gl.texElementImage2D) {
try {
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, uiElement);
} catch (err) {
console.error('texElementImage2D copy failed:', err);
}
}
};
```
When using a `requestAnimationFrame` loop to render the scene, call `canvas.requestPaint()` within the loop to ensure that the HTML content is rendered to the canvas. Make sure you only re-render the canvas if there has been an update to the descendant HTML elements:
```js
function render() {
// Request to update the canvas
canvas.requestPaint();
requestAnimationFrame(render);
}
requestAnimationFrame(render);
canvas.onpaint = (event) => {
if (event.changedElements && event.changedElements.length > 0) {
// Update the texture with texElementImage2D, and update the CSS transform as shown in step 6
}
};
```
6. Update the CSS transform.
The browser needs to map from the 3D coordinate space into the CSS coordinate space using a viewport transform. To facilitate this, do the following:
- Convert WebGL MVP Matrix to DOM Matrix.
- Normalize the HTML element. HTML elements are sized in pixels (for example, 200px wide). WebGL, however, usually treats objects as "unit squares", for example, ranging from 0 to 1. If you don't normalize, your 200px button will look 200 times larger.
- Map to the canvas viewport. This step is the "re-scaling" phase: it stretches that unit-space math back out to match the actual pixel dimensions of your `<canvas>` element on the screen. It also flips the Y-axis, because in WebGL, up is positive, but in CSS, down is positive.
- Calculate the final transform. Multiply the matrices in order: Viewport * MVP * Normalization. Combining them into one final transform produces a "map" that tells the browser exactly where that HTML element layer should sit to align with the 3D drawing.
- Apply the transform to the HTML element. This moves the HTML element layer to sit directly on top of its rendered pixels. This ensures that when a user clicks a button or selects text, they are actually hitting the real HTML element.
```js
if (canvas.getElementTransform) {
// 1. Convert WebGL MVP Matrix to DOM Matrix
const mvpDOM = new DOMMatrix(Array.from(htmlElementMVP));
// 2. Normalize the HTML element (Canvas Grid pixels -> WebGL Model Space)
const dprX = canvas.width / canvas.clientWidth;
const dprY = canvas.height / canvas.clientHeight;
const gridWidth = targetHTMLElement.offsetWidth * dprX;
const gridHeight = targetHTMLElement.offsetHeight * dprY;
const toGLModel = new DOMMatrix()
// Scale pixels to 1 unit, flip Y (as in CSS it points down, and in WebGL it points up)
.scale(1 / gridWidth, -1 / gridHeight, 1 / gridHeight)
// Center the origin: (0,0) becomes (-width/2, -height/2) before scaling
.translate(-gridWidth / 2, -gridHeight / 2);
// 3. Map to the canvas viewport
const clipToCanvasViewport = new DOMMatrix()
// Move center (0,0) to center of canvas
.translate(canvas.width / 2, canvas.height / 2)
// Scale normalized clip (-1..1) to viewport size
.scale(canvas.width / 2, -canvas.height / 2, canvas.height / 2);
// 4. Multiply: (Clip -> Pixels) * (MVP) * (pixels -> unit square)
const screenSpaceTransform = clipToCanvasViewport
.multiply(mvpDOM)
.multiply(toGLModel);
// 5. Apply to the transform
const computedTransform = canvas.getElementTransform(
targetHTMLElement,
screenSpaceTransform,
);
targetHTMLElement.style.transform = computedTransform.toString();
}
```
7. [Troubleshooting] If the developer is experiencing a mismatch in the DOM logical layout in 3D even after applying the CSS transform from step 5, check if the developer is experiencing the issue in Chromium 148 or earlier. If that's the case, check if `transform.is2D` is correctly set to false for a 3D DOMMatrix. If not, re-initialize the DOMMatrix which corrects `is2D` to be false before applying the transform to the target HTML element. This issue is fixed in Chromium 149+, and if the developer is experiencing it in newer Chromium versions, the is2D value is not the cause:
```js
if (transform.is2D) {
// Workaround for Chromium bug https://crbug.com/512171941
// affecting Chrome versions under 149 where `transform.is2D`
// is incorrectly true for a 3D DOMMatrix. The assignment
// below re-initializes the DOMMatrix which corrects is2D to be false.
transform = DOMMatrix.fromFloat64Array(transform.toFloat64Array());
}
targetHTMLElement.style.transform = computedTransform.toString();
```
## Example code
```html
<canvas id="canvas" layoutsubtree style="width: 400px; height: 400px;">
<div id="ui-element">
<p>WebGL UI Element</p>
<button>Action</button>
</div>
</canvas>
<script>
const canvas = document.getElementById("canvas");
const gl = canvas.getContext("webgl");
const uiElement = document.getElementById("ui-element");
// Setup WebGL texture...
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
canvas.onpaint = () => {
// 1. Update texture with HTML content
if (gl.texElementImage2D) {
try {
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, uiElement);
} catch (err) {
console.error('texElementImage2D copy failed:', err);
}
}
// ... Render your 3D scene here, calculating htmlElementMVP matrix ...
// 2. Sync DOM position with 3D scene
if (canvas.getElementTransform) {
const mvpDOM = new DOMMatrix(Array.from(htmlElementMVP));
// Recalculate the DPR compensation mapping
const dprX = canvas.width / canvas.clientWidth;
const dprY = canvas.height / canvas.clientHeight;
const gridWidth = uiElement.offsetWidth * dprX;
const gridHeight = uiElement.offsetHeight * dprY;
const cssToUnitSpace = new DOMMatrix()
.scale(1 / gridWidth, -1 / gridHeight, 1 / gridHeight)
.translate(-gridWidth / 2, -gridHeight / 2);
const clipToCanvasViewport = new DOMMatrix()
.translate(canvas.width / 2, canvas.height / 2)
.scale(canvas.width / 2, -canvas.height / 2, canvas.height / 2);
const screenSpaceTransform = clipToCanvasViewport
.multiply(mvpDOM)
.multiply(cssToUnitSpace);
const computedTransform = canvas.getElementTransform(
uiElement,
screenSpaceTransform,
);
uiElement.style.transform = computedTransform.toString();
}
};
</script>
```
## Best Practices
- **MANDATORY**: Check browser support for the HTML-in-Canvas API before using it.
- **MANDATORY**: Always add the `layoutsubtree` attribute to the `<canvas>` element.
- **MANDATORY**: Use an `onpaint` event handler to render the HTML content to the canvas.
- **MANDATORY**: Use the `texElementImage2D` method to render the HTML content to the canvas.
- **MANDATORY**: Update the CSS transform of the HTML element to match the transform of the rendered content by setting the `style.transform` property of the HTML element.
- **MANDATORY**: Use `ResizeObserver` to observe the screen size and update the canvas size to match device pixels.
- **DO NOT** embed cross-origin content in a canvas, as it is not supported.
- **DO NOT** initialize `ResizeObserver` within the `onpaint` event handler, as it may lead to memory leaks.
### Fallback strategies
HTML in canvas is not natively supported by any major browser yet.
The HTML-in-Canvas API is not currently supported in all modern browsers, thus a fallback strategy is typically required.
However, given the improved performance benefits of this API, HTML-in-Canvas should be used if the browser supports it.
The fallback strategy depends on the use case. For example, for an interactive HTML content in canvas, if HTML-in-Canvas is not supported, place the HTML content on top of the canvas using CSS.
### HTML-in-Canvas polyfill
Use the following polyfill script to mimic the HTML-in-Canvas API in browsers that do not support it.
1. Install or embed the library:
```
# Install
npm install three-html-render
```
```
# Embed
<script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>
```
2. Run the `installHtmlInCanvasPolyfill()` method to translate HTML-in-Canvas.
guides/visual-design/complex-shapes.md
# Complex Shapes
## Overview
To clip elements to complex, free-form shapes like brush strokes or organic textures, use CSS Masking (`mask-image`). While `clip-path` is excellent for geometric shapes or vector paths, `mask-image` allows you to use images (like PNGs with transparency) or SVGs to define the visible area of an element. This approach is more expressive because it supports semi-transparency, allowing for soft edges and complex textures that are difficult or impossible to achieve with `clip-path`.
## Implementation
To implement complex shapes using CSS masks:
### Using transparency from an image
You can use the transparency of an image as a mask, with opaque parts visible and transparent parts hidden. This can be a PNG, SVG, or other image with transparency, or a generated image, like a CSS gradient.
```css
.shaped-element {
/* MANDATORY: Use vendor prefix for wider support in older browsers */
-webkit-mask-image: url('mask.svg');
-webkit-mask-size: cover; /* Scale mask to cover element */
-webkit-mask-repeat: no-repeat; /* Do not tile the mask */
/* Standard property for modern browsers */
mask-image: url('mask.svg');
mask-size: cover;
mask-repeat: no-repeat;
}
```
### Using an SVG element in HTML
You can also reference a `<mask>` element defined in an inline SVG in your page's HTML. Use `maskContentUnits="objectBoundingBox"` to make the mask scale automatically with the size of the element. This tells the browser to interpret all coordinates inside the mask as fractions from `0` to `1` (like `0.5` for 50%) instead of absolute pixels.
> **Luminance vs. Alpha Masking**: By default, SVG masks use **luminance** (brightness) to determine opacity, where white reveals, black hides, and gray creates semi-transparency. If you want the mask to use the **alpha channel** (transparency) of your SVG shapes instead, you can specify `mask-type: alpha;` in your CSS or `mask-type="alpha"` directly on the SVG `<mask>` element.
```html
<!-- White areas reveal content, gray creates semi-transparency, black or transparent hides it -->
<svg width="0" height="0">
<defs>
<!-- objectBoundingBox scales mask coordinates (0 to 1) with the element's size -->
<mask id="custom-shape" maskContentUnits="objectBoundingBox">
<!-- Use white shapes to define fully opaque areas -->
<circle cx="0.5" cy="0.5" r="0.5" fill="white" />
<!-- Use gray shapes to define semi-transparent/faded areas -->
<circle cx="0.5" cy="0.5" r="0.25" fill="gray" />
</mask>
</defs>
</svg>
<div class="masked-content">
<!-- Content to be masked -->
</div>
<style>
.masked-content {
/* Reference the SVG mask ID */
-webkit-mask-image: url(#custom-shape);
mask-image: url(#custom-shape);
}
</style>
```
### Fallback strategies
Baseline status for Masks: Widely available. It's been Baseline since 2023-12-07.
Supported by: Chrome 120 (Dec 2023), Edge 120 (Dec 2023), Firefox 53 (Apr 2017), and Safari 15.4 (Mar 2022).
If a browser does not support `mask-image` or the prefixed version:
- The element will not be clipped and will display as a normal rectangle.
- Ensure the content is still readable and the layout does not break without the mask (progressive enhancement).
- Optionally, use feature detection to provide a simpler fallback shape with `clip-path`.
```css
/* Fallback for browsers that do not support masking */
@supports (not (mask-image: url(x))) and (not (-webkit-mask-image: url(x))) {
.shaped-element {
/* Use a simple rounded rectangle as fallback */
clip-path: inset(5% round 15px);
}
}
```
guides/visual-design/customize-scrollbar-color-and-thickness.md
# Customize the color or thickness of a scrollbar
You can customize the appearance of scrollbars using the standard CSS properties `scrollbar-color` and `scrollbar-width`.
* **`scrollbar-color`**: Accepts two `<color>` values. The first applies to the thumb (the moving part), and the second to the track (the fixed background).
* **`scrollbar-width`**: Accepts `auto` (default), `thin` (a thinner variant), or `none` (hides the scrollbar completely while maintaining scrollability).
## Apply `scrollbar-color` and `scrollbar-width`
MANDATORY: Use `scrollbar-color` and `scrollbar-width` on the scrollable container.
When using `scrollbar-color`, use CSS variables to keep thumb and track colors separate, for readability and maintainability (especially when using fallbacks).
```css
.scroller {
--scrollbar-thumb: var(--color-neutral-70);
--scrollbar-track: var(--color-neutral-90);
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
```
## Fallback strategies
Baseline status for scrollbar-width: Newly available. It's been Baseline since 2024-12-11.
Supported by: Chrome 121 (Jan 2024), Edge 121 (Jan 2024), Firefox 64 (Dec 2018), and Safari 18.2 (Dec 2024).
### Fallbacks & browser support for scrollbar-color
Baseline status for scrollbar-color: Newly available. It's been Baseline since 2025-12-12.
Supported by: Chrome 121 (Jan 2024), Edge 121 (Jan 2024), Firefox 64 (Dec 2018), and Safari 26.2 (Dec 2025).
This feature is progressive enhancement and does not always require fallbacks.
If the styling is important and the user's Baseline target is "Baseline Widely Available" or earlier, you SHOULD include the non-standard `::-webkit-scrollbar` pseudo-elements as fallbacks.
Wrap legacy fallbacks in an `@supports not (scrollbar-color: auto)` block to prevent conflicts between standard properties and legacy WebKit selectors in browsers that support both natively.
If you are using custom properties to define colors, these will cascade to the legacy WebKit selectors automatically. You do NOT need to duplicate them.
```css
/* Legacy fallback for WebKit/Blink browsers */
@supports not (scrollbar-color: auto) {
.scroller::-webkit-scrollbar {
/* Must define base size in WebKit for custom colors to be visual */
width: 12px;
height: 12px;
}
.scroller::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
}
.scroller::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
}
```
guides/visual-design/dark-mode.md
# Dark mode
The `color-scheme` property indicates which color schemes (such as light or dark) your page supports. This informs the browser that it can automatically theme native UI elements—like scrollbars, form controls, and the default canvas background—to match your site's design and help minimize white flashes during initial loading.
## Implementation
### 1. Declare supported schemes in HTML
MANDATORY: To help prevent a "flash of un-themed content" (FOUC), place a `<meta>` tag in your `<head>` to ensure the browser knows which themes you support before it even starts rendering. While this `<meta>` tag helps to avoid FOUC by setting the initial canvas color early, it may not completely eliminate flashes in all browsers or loading conditions.
```html
<!-- MANDATORY: Declare support for both light and dark themes -->
<meta name="color-scheme" content="light dark">
```
### 2. Apply page-wide color scheme to CSS :root or html
MANDATORY: Apply the `color-scheme` property to the `html` element or the `:root` pseudo-class. Browsers specifically look to the root element to determine the theme for the entire viewport—including the root scrollbars and the initial "canvas" background. If applied only to the `body`, these global UI surfaces may remain in light mode because the `body` does not control the window's rendering context.
```css
/* MANDATORY: Apply color-scheme to :root or html for viewport-wide theming */
:root {
/* MANDATORY: Automatically adapt native UI to user system preferences */
color-scheme: light dark;
}
```
### 3. Define light and dark color tokens
You can use the `light-dark()` function to define color tokens that automatically adapt to different `color-scheme` values.
It is recommended that you also keep the raw color values in separate custom properties, which makes it easier to combine them in different ways (and makes fallback behavior easier, if needed).
For more control over the colors of built-in UI such as `accent-color` or `scrollbar-color`, authors **can optionally** add their own dynamic colors with use of custom properties and/or the `light-dark()` function. This function automatically picks the correct color based on the computed `color-scheme` of the element and eliminates the need for redundant media queries, but is not required for a basic implementation.
```css
:root {
--color-brand-light: oklch(45% 0.23 270);
--color-brand-dark: oklch(85% 0.15 210);
--color-brand-text-light: white;
--color-brand-text-dark: oklch(40% 0.23 270);
--color-brand: light-dark(var(--color-brand-light), var(--color-brand-dark));
--color-brand-text: light-dark(var(--color-brand-text-light), var(--color-brand-text-dark));
/* MANDATORY: Automatically adapt native UI to user system preferences */
color-scheme: light dark;
}
button.primary {
/* These automatically adapt to color scheme */
background-color: var(--color-brand);
color: var(--color-brand-text);
}
```
OPTIONAL: A number of system colors are available, which also automatically adapt to the used color scheme (and other color modes, e.g. forced colors), such as `canvas`, `canvastext`, `accentcolor` (check support) , `buttonborder` etc. These are typically too limited to be useful, beyond very specific cases where you need to exactly match certain default browser UI or as fallbacks/defaults.
#### OPTIONAL: Tailor color pairs to context
Even when overriding the system default, it can be useful to use the `prefers-color-scheme` media query to define **different** color pairs that take into account the colors of the browser and OS chrome around the page (or of the surrounding page, when the page is used as an iframe).
For example, use a slightly dimmer light theme when the system setting is `dark`, or a more contrasting dark theme when the system setting is `light`, so the page is not visually overpowered by the surrounding UI.
## Fine-grained browser UI customization
Setting `color-scheme` already adapts browser UI to the used color scheme, but this will use OS defaults and/or system colors that may not perfectly align with the website design.
Modern browsers expose several fine-grained customization hooks for these.
Do not reimplement native controls simply to customize their appearance without exhausting the customization hooks modern browsers provide.
### Setting the accent color
Some browser UI (e.g. checked checkboxes or sliders) uses an accent color.
This resolves to the OS setting by default, but you can use the `accent-color` property to set it to a color that better aligns with the page, such as the page's brand color.
```css
html {
accent-color: light-dark(var(--color-accent-light), var(--color-accent-dark));
}
```
### Issues to be aware of when using accent-color
- When placing visual elements over the accent color (e.g. a checkbox checkmark), Chrome and Safari will automatically select a contrasting color, whereas Safari will modify the accent color, and may not maintain adequate contrast.
### Scrollbar colors
You can use `scrollbar-color` together with `light-dark()` to set custom scrollbar colors that adapt to the color scheme used.
```css
:root {
--color-scrollbar-track: light-dark(#eee, #222);
--color-scrollbar-thumb: light-dark(#999, #666);
scrollbar-color: var(--color-scrollbar-thumb) var(--color-scrollbar-track);
}
```
### Issues to be aware of when using scrollbar-color
- Do NOT animate or transition `scrollbar-color`. A [WebKit bug](https://bugs.webkit.org/show_bug.cgi?id=311752) causes the scrollbar to flicker every time `scrollbar-color` changes.
- On macOS, `scrollbar-color` (standard) and `::-webkit-scrollbar` (legacy) properties are ignored by default because macOS uses native "overlay" scrollbars. You MUST pair custom colors with `scrollbar-width` (e.g., `thin` or `auto`) to force macOS to render them.
- Even with `scrollbar-width` applied, macOS overlay scrollbars render the track (gutter) as transparent by default. If the design requires a visible track background color on MacOS, you MUST apply `scrollbar-gutter: stable;` to the scrollable container, but note that it only appears after the user hovers over the scrollbar.
- Even with `scrollbar-gutter: stable` the track may be transparent on MacOS. The thumb should not depend on the track color to be visible.
### Further customization
Most browser UI exposes pseudo-elements to fully customize its appearance, such as:
- `::placeholder`
- `::spelling-error`
- `::grammar-error`
- `::selection`
- `::search-text`
- `::target-text`
- `::file-selector-button`
You can use `light-dark()` colors on any of these to apply colors that adapt to the used color scheme.
## OPTIONAL: Implementing a color-scheme toggle
**DO NOT** set `color-scheme: light` or `color-scheme: dark` on the root element by default.
The default color-scheme MUST be the user's system preference, which happens automatically when setting `color-scheme` to `light dark`.
For website-specific customization, a manual toggle could be provided to allow users to choose between light, dark, or system-default modes.
If a user-facing toggle to override it is desired, it should:
- Update the `<meta name="color-scheme">` element to reflect the chosen theme (`light dark` for system default, `light` for light, and `dark` for dark).
- If branching is desired for non-color values, set a class on `<html>` to match the theme preference and use descendant selectors. While `:root:has(> head > meta[name="color-scheme"][content="dark"])` would technically work, it is slower and confers no benefit, since we are already using JS to update the `<meta>` element.
- Persist user choice in `localStorage`.
- **IMPORTANT**: The CSS should be written to default to the system preference, with overrides for user-specified color-schemes. That way, if JS fails to execute, the site still defaults to the system color-scheme.
- The system-level OS theme can change at any time. If you are using JS to read `matchMedia("(prefers-color-scheme: dark)").matches`, you MUST also use `addEventListener("change", fn)` to react to changes. CSS automatically adapts to changes.
- **IMPORTANT**: To avoid a Flash of Unstyled Content (FOUC) for users who have pinned a different color scheme than their system default, use an inline script (NOT `type=module`, NOT `defer`) to set it when the page loads:
```html
<meta name="color-scheme" content="light dark">
<script>
{
const colorScheme = localStorage.getItem("color-scheme");
if (colorScheme) {
document.querySelector('meta[name="color-scheme"]').content = colorScheme;
}
}
</script>
```
### UX considerations
Use a two-state control:
1. System setting.
2. The opposite (e.g. light when the system setting is dark, and dark when the system setting is light). Selecting this setting must pin that exact color scheme, not a dynamically computed "opposite of system setting" value. Example scenario:
1. The OS is set to light mode.
2. The user selects the opposite setting for this website (dark).
3. The user changes their system setting to dark.
4. The website should remain dark.
**DON'T** expose all three states (system, light, dark). While the rationale is plausible — "Follow system (currently dark)" is a distinct user intent from "Always dark" — it provides suboptimal UX:
- Users cannot meaningfully express intent for problems they don't currently have. A manual toggle is a temporary comfort adjustment ("it's too bright right now"), not a long-term preference ("make sure this never changes").
- Two of the three options always produce the same visual result, violating the principle of feedback.
## Component-specific overrides
You can override the global theme for specific elements by setting `color-scheme` on them.
This is useful for "dark mode" sections within a light-themed site, such as code blocks or media players.
```css
pre, code {
/* Forces element and its children to use dark themed UI */
color-scheme: dark;
}
```
For more information about component-specific overrides and their gotchas, see `component-specific-light-dark-theme` (via `npx -y modern-web-guidance@latest retrieve "component-specific-light-dark-theme"`).
## Known issues to be aware of
### Issues to be aware of when using color-scheme
- Chrome and Firefox respect `color-scheme` for iframes: they render embedded pages in the correct color scheme and adjust the embedded page's `prefers-color-scheme` media query to reflect the embedding context's `color-scheme`. Safari does not, and resolves `prefers-color-scheme` to the system setting even inside iframes.
- **If you control both parent and iframe:** pass the parent's color scheme to the iframe explicitly — via a URL parameter (`?theme=dark`) at iframe construction time, or via `postMessage()` (which also lets you react to runtime changes). In the iframe, set a class on `<html>` (and/or `color-scheme` on `:root`) from that signal instead of relying on `prefers-color-scheme`.
- **If you only control the embedded page:** there is no reliable way to detect the embedding context's `color-scheme` from inside the iframe in Safari. Expose an explicit theme parameter on your embed API (e.g. a query string or `postMessage` protocol) and document it for embedders.
## Fallback strategies
### Fallbacks & browser support for color-scheme
Baseline status for color-scheme: Widely available. It's been Baseline since 2022-02-03.
Supported by: Chrome 98 (Feb 2022), Edge 98 (Feb 2022), Firefox 96 (Jan 2022), and Safari 13 (Sep 2019).
The `color-scheme` property is **progressive enhancement**.
Browsers that do not support it will ignore this property and use their default light-mode UI.
To adapt to the user's preferences in older browsers, use `prefers-color-scheme` media queries to provide different colors when dark mode is preferred.
- DO use the media query to switch custom properties on `:root` or `html`
- Avoid using the media query on individual components unless the component requires a very specific type of dark mode customization beyond colors.
```css
:root {
/* Define brand colors for each mode */
--color-brand-light: #0056b3;
--color-brand-dark: #00e5ff;
--color-brand: var(--color-brand-light);
/* MANDATORY: Fallback for browsers without light-dark support */
@media (prefers-color-scheme: dark) {
--color-brand: var(--color-brand-dark);
}
/* Ignored in older browsers */
color-scheme: light dark;
}
button.primary {
background-color: var(--color-brand);
}
```
### Fallbacks & browser support for light-dark()
Baseline status for light-dark(): Newly available. It's been Baseline since 2024-05-13.
Supported by: Chrome 123 (Mar 2024), Edge 123 (Mar 2024), Firefox 120 (Nov 2023), and Safari 17.5 (May 2024).
For browsers that support `color-scheme` but not yet `light-dark()`, light and dark versions of colors should first be defined as custom properties, and the `prefers-color-scheme` media query should be used to set colors for the respective mode like in the example below:
```css
:root {
/* Define browser UI accent color for each mode */
--brand-accent-light: #0056b3;
--brand-accent-dark: #00e5ff;
--accent-color: var(--brand-accent-light);
/* MANDATORY: Fallback for browsers without light-dark support */
@media (prefers-color-scheme: dark) {
--accent-color: var(--brand-accent-dark);
}
/* OPTIONAL: use light-dark() for more control of built-in UI colors */
@supports (color: light-dark(white, black)) {
--accent-color: light-dark(var(--brand-accent-light), var(--brand-accent-dark));
}
/* MANDATORY: Automatically adapt native UI to user system preferences */
color-scheme: light dark;
/* Example inherited color property */
accent-color: var(--accent-color);
}
pre, code {
color-scheme: dark;
/* **Mandatory**: any inherited color properties must be set again, even if to the same design tokens */
accent-color: var(--accent-color);
}
```
### Fallbacks & browser support for scrollbar-color
Baseline status for scrollbar-color: Newly available. It's been Baseline since 2025-12-12.
Supported by: Chrome 121 (Jan 2024), Edge 121 (Jan 2024), Firefox 64 (Dec 2018), and Safari 26.2 (Dec 2025).
This feature is progressive enhancement and does not always require fallbacks.
If the styling is important and the user's Baseline target is "Baseline Widely Available" or earlier, you SHOULD include the non-standard `::-webkit-scrollbar` pseudo-elements as fallbacks.
Wrap legacy fallbacks in an `@supports not (scrollbar-color: auto)` block to prevent conflicts between standard properties and legacy WebKit selectors in browsers that support both natively.
If you are using custom properties to define colors, these will cascade to the legacy WebKit selectors automatically. You do NOT need to duplicate them.
```css
/* Legacy fallback for WebKit/Blink browsers */
@supports not (scrollbar-color: auto) {
.scroller::-webkit-scrollbar {
/* Must define base size in WebKit for custom colors to be visual */
width: 12px;
height: 12px;
}
.scroller::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
}
.scroller::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
}
```
### Fallbacks & browser support for accent-color
accent-color has limited availability.
Supported by: Chrome 93 (Aug 2021), Edge 93 (Sep 2021), Firefox 92 (Sep 2021), and Safari 26.2 (Dec 2025).
The `accent-color` property is progressive enhancement.
Browsers that do not support this property will ignore it and use their default UI colors.
guides/visual-design/export-html-media-from-canvas.md
# Export HTML content from canvas
Web applications frequently need to capture and export rich HTML content—such as customized dashboards, styled documents, or interactive charts—as static images or video recordings. Historically, achieving this required bulky third-party libraries that manually parse DOM nodes and CSS properties to reconstruct a visual facsimile on a canvas. This approach is computationally expensive, error-prone, and frequently fails to support modern CSS layout features. With the HTML-in-Canvas API, developers can render real DOM elements directly into the canvas context. Because the browser's native rendering engine paints the HTML subtree with pixel-perfect accuracy, capturing the exact visual output as an image or video stream is highly efficient using built-in canvas methods like `toDataURL()`, `toBlob()`, or `captureStream()`.
## How to implement
1. Check if HTML-in-Canvas is supported in the browser:
```
if ('requestPaint' in HTMLCanvasElement.prototype) {
// Use HTML in Canvas API
} else {
// Use fallback strategy
}
```
2. Initialize the canvas to support rendering of descendant HTML elements by adding the `layoutsubtree` attribute to the `<canvas>` HTML element. Place your HTML content inside the `<canvas>` element with the `layoutsubtree` attribute:
```html
<canvas id="canvas" layoutsubtree>
<div id="html-content"></div>
</canvas>
```
3. Scale your canvas grid to match the device scale factor to prevent blurriness:
```js
const observer = new ResizeObserver(([entry]) => {
const dpc = entry.devicePixelContentBoxSize;
canvas.width = dpc
? dpc[0].inlineSize
: Math.round(entry.contentRect.width * window.devicePixelRatio);
canvas.height = dpc
? dpc[0].blockSize
: Math.round(entry.contentRect.height * window.devicePixelRatio);
});
const supportsDevicePixelContentBox =
typeof ResizeObserverEntry !== "undefined" &&
"devicePixelContentBoxSize" in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox
? { box: "device-pixel-content-box" }
: {};
observer.observe(canvas, options);
```
4. Render the HTML content to the canvas inside a `canvas.onpaint` event handler:
- In 2D context, use the `drawElementImage` method:
```js
canvas.onpaint = () => {
ctx.reset();
// Draw the form element at x:0, y:0
let transform = ctx.drawElementImage(form_element, 0, 0);
};
```
- In WebGL context, use the `texElementImage2D` method:
```js
canvas.onpaint = () => {
if (gl.texElementImage2D) {
try {
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, uiElement);
} catch (err) {
console.error('texElementImage2D copy failed:', err);
}
}
};
```
- In WebGPU context, use the `copyElementImageToTexture` method:
```js
canvas.onpaint = () => {
if (root.device.queue.copyElementImageToTexture) {
try {
const sourceDict = { source: valueElement };
const destDict = {
destination: { texture: targetTexture },
width: 512,
height: 128,
};
root.device.queue.copyElementImageToTexture(sourceDict, destDict);
} catch (err) {
console.error('copyElementImageToTexture copy failed:', err);
}
}
};
```
When using a `requestAnimationFrame` loop to render the scene, call `canvas.requestPaint()` within the loop to ensure that the HTML content is rendered to the canvas. Make sure you only re-render the canvas if there has been an update to the descendant HTML elements:
```js
function render() {
// Request to update the canvas
canvas.requestPaint();
requestAnimationFrame(render);
}
requestAnimationFrame(render);
canvas.onpaint = (event) => {
if (event.changedElements && event.changedElements.length > 0) {
// Update the texture with drawElementImage, texElementImage2D, or copyElementImageToTexture, and update the CSS transform as shown in step 5
}
};
```
5. Update the CSS transform.
- For the 2D context case, apply the transform returned by the rendering call to the `style.transform` property:
```js
canvas.onpaint = () => {
ctx.reset();
// Draw the form element at x:0, y:0
let transform = ctx.drawElementImage(form_element, 0, 0);
// Sync the DOM location with the drawn location
form_element.style.transform = transform.toString();
};
```
- For the 3D case with WebGL or WebGPU, the browser needs to map from the 3D coordinate space into the CSS coordinate space using a viewport transform. To facilitate this, do the following:
- Convert WebGL MVP Matrix to DOM Matrix.
- Normalize the HTML element. HTML elements are sized in pixels (for example, 200px wide). WebGL, however, usually treats objects as "unit squares", for example, ranging from 0 to 1. If you don't normalize, your 200px button will look 200 times larger.
- Map to the canvas viewport. This step is the "re-scaling" phase: it stretches that unit-space math back out to match the actual pixel dimensions of your `<canvas>` element on the screen. It also flips the Y-axis, because in WebGL, up is positive, but in CSS, down is positive.
- Calculate the final transform. Multiply the matrices in order: Viewport * MVP * Normalization. Combining them into one final transform produces a "map" that tells the browser exactly where that HTML element layer should sit to align with the 3D drawing.
- Apply the transform to the HTML element. This moves the HTML element layer to sit directly on top of its rendered pixels. This ensures that when a user clicks a button or selects text, they are actually hitting the real HTML element.
```js
if (canvas.getElementTransform) {
// 1. Convert WebGL MVP Matrix to DOM Matrix
const mvpDOM = new DOMMatrix(Array.from(htmlElementMVP));
// 2. Normalize the HTML element (Canvas Grid pixels -> WebGL Model Space)
const dprX = canvas.width / canvas.clientWidth;
const dprY = canvas.height / canvas.clientHeight;
const gridWidth = targetHTMLElement.offsetWidth * dprX;
const gridHeight = targetHTMLElement.offsetHeight * dprY;
const toGLModel = new DOMMatrix()
// Scale pixels to 1 unit, flip Y (as in CSS it points down, and in WebGL it points up)
.scale(1 / gridWidth, -1 / gridHeight, 1 / gridHeight)
// Center the origin: (0,0) becomes (-width/2, -height/2) before scaling
.translate(-gridWidth / 2, -gridHeight / 2);
// 3. Map to the canvas viewport
const clipToCanvasViewport = new DOMMatrix()
// Move center (0,0) to center of canvas
.translate(canvas.width / 2, canvas.height / 2)
// Scale normalized clip (-1..1) to viewport size
.scale(canvas.width / 2, -canvas.height / 2, canvas.height / 2);
// 4. Multiply: (Clip -> Pixels) * (MVP) * (pixels -> unit square)
const screenSpaceTransform = clipToCanvasViewport
.multiply(mvpDOM)
.multiply(toGLModel);
// 5. Apply to the transform
const computedTransform = canvas.getElementTransform(
targetHTMLElement,
screenSpaceTransform,
);
targetHTMLElement.style.transform = computedTransform.toString();
}
```
6. [Troubleshooting] If the developer is experiencing a mismatch in the DOM logical layout in 3D even after applying the CSS transform from step 5, check if the developer is experiencing the issue in Chromium 148 or earlier. If that's the case, check if `transform.is2D` is correctly set to false for a 3D DOMMatrix. If not, re-initialize the DOMMatrix which corrects `is2D` to be false before applying the transform to the target HTML element. This issue is fixed in Chromium 149+, and if the developer is experiencing it in newer Chromium versions, the is2D value is not the cause:
```js
if (transform.is2D) {
// Workaround for Chromium bug https://crbug.com/512171941
// affecting Chrome versions under 149 where `transform.is2D`
// is incorrectly true for a 3D DOMMatrix. The assignment
// below re-initializes the DOMMatrix which corrects is2D to be false.
transform = DOMMatrix.fromFloat64Array(transform.toFloat64Array());
}
targetHTMLElement.style.transform = computedTransform.toString();
```
7. Use regular canvas export methods like `toDataURL()`, `toBlob()`, or `captureStream()`. The exported data will include the rendered HTML content.
## Example code
```html
<body>
<canvas id="canvas" style="width: 400px; height: 200px;" layoutsubtree>
<input id="element">
</canvas>
<button id="download">Download Image</button>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const element = document.getElementById('element');
const download = document.getElementById('download');
canvas.onpaint = (event) => {
ctx.reset();
// Draw the element into the canvas
const transform = ctx.drawElementImage(element, 10, 10);
// Synchronize DOM position for hit testing (typing)
element.style.transform = transform.toString();
};
download.onclick = () => {
// Export the canvas content as an image
const dataURL = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.download = 'exported-canvas.png';
link.href = dataURL;
link.click();
};
// Re-initialize canvas size on screen resize
const observer = new ResizeObserver(([entry]) => {
const dpc = entry.devicePixelContentBoxSize;
canvas.width = dpc ? dpc[0].inlineSize : Math.round(entry.contentRect.width * window.devicePixelRatio);
canvas.height = dpc ? dpc[0].blockSize : Math.round(entry.contentRect.height * window.devicePixelRatio);
canvas.requestPaint();
});
const supportsDevicePixelContentBox =
typeof ResizeObserverEntry !== 'undefined' &&
'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox ? { box: 'device-pixel-content-box' } : {};
observer.observe(canvas, options);
</script>
</body>
```
## Best Practices
- **MANDATORY**: Check browser support for the HTML-in-Canvas API before using it.
- **MANDATORY**: Always add the `layoutsubtree` attribute to the `<canvas>` element.
- **MANDATORY**: Use an `onpaint` event handler to render the HTML content to the canvas.
- **MANDATORY**: Use the `drawElementImage`, `texElementImage2D`, or `copyElementImageToTexture` methods to render the HTML content to the canvas.
- **MANDATORY**: Update the CSS transform of the HTML element to match the transform of the rendered content by setting the `style.transform` property of the HTML element.
- **MANDATORY**: Use `ResizeObserver` to observe the screen size and update the canvas size to match device pixels.
- **DO NOT** embed cross-origin content in a canvas, as it is not supported.
- **DO NOT** initialize `ResizeObserver` within the `onpaint` event handler, as it may lead to memory leaks.
## Fallback strategies
HTML in canvas is not natively supported by any major browser yet.
The HTML-in-Canvas API is not currently supported in all modern browsers, thus a fallback strategy is typically required. However, given the improved performance benefits of this API, HTML-in-Canvas should be used if the browser supports it.
For the use case where HTML content needs to be exported from a canvas, use libraries like `html2canvas`, `dom-to-image`, or `snapdom`.
To capture HTML interactions frame by frame, for example, for streaming, capture DOM mutations using libraries like `rrweb`.
Alternatively, implement a warning that HTML media export is not supported in the browser because it doesn't support HTML-in-Canvas.guides/visual-design/expose-canvas-content-to-browser-features.md
# Expose canvas content to browser features
Regular `<canvas>` content is not exposed to browser features such as screen readers, indexing, translation tools, accessibility assistive tools, find-in-page, print, etc. With `HTML in canvas`, you can render real DOM directly in a canvas element. Adding the `layoutsubtree` attribute to a `<canvas>` HTML element allows rendering descendant HTML elements within the canvas's rendering context. You can use it to style and lay out text in a canvas, expose canvas content to browser features (like accessibility, translation, or find-in-page), and apply 2D and 3D effects to HTML.
## How to implement
1. Check if HTML-in-Canvas is supported in the browser:
```
if ('requestPaint' in HTMLCanvasElement.prototype) {
// Use HTML in Canvas API
} else {
// Use fallback strategy
}
```
2. Add the `layoutsubtree` attribute to the `<canvas>` HTML element.
3. Place your HTML content inside the `<canvas>` element with the `layoutsubtree` attribute.
```html
<canvas id="canvas" layoutsubtree>
<div id="html-content"></div>
</canvas>
```
4. Scale your canvas grid to match the device scale factor to prevent blurriness:
```js
const observer = new ResizeObserver(([entry]) => {
const dpc = entry.devicePixelContentBoxSize;
canvas.width = dpc
? dpc[0].inlineSize
: Math.round(entry.contentRect.width * window.devicePixelRatio);
canvas.height = dpc
? dpc[0].blockSize
: Math.round(entry.contentRect.height * window.devicePixelRatio);
});
const supportsDevicePixelContentBox =
typeof ResizeObserverEntry !== "undefined" &&
"devicePixelContentBoxSize" in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox
? { box: "device-pixel-content-box" }
: {};
observer.observe(canvas, options);
```
5. Render the HTML content to the canvas inside a `canvas.onpaint` event handler:
- In 2D context, use the `drawElementImage` method:
```js
canvas.onpaint = () => {
ctx.reset();
// Draw the form element at x:0, y:0
let transform = ctx.drawElementImage(form_element, 0, 0);
};
```
- In WebGL context, use the `texElementImage2D` method:
```js
canvas.onpaint = () => {
if (gl.texElementImage2D) {
try {
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, uiElement);
} catch (err) {
console.error('texElementImage2D copy failed:', err);
}
}
};
```
- In WebGPU context, use the `copyElementImageToTexture` method:
```js
canvas.onpaint = () => {
if (root.device.queue.copyElementImageToTexture) {
try {
const sourceDict = { source: valueElement };
const destDict = {
destination: { texture: targetTexture },
width: 512,
height: 128,
};
root.device.queue.copyElementImageToTexture(sourceDict, destDict);
} catch (err) {
console.error('copyElementImageToTexture copy failed:', err);
}
}
};
```
When using a `requestAnimationFrame` loop to render the scene, call `canvas.requestPaint()` within the loop to ensure that the HTML content is rendered to the canvas. Make sure you only re-render the canvas if there has been an update to the descendant HTML elements:
```js
function render() {
// Request to update the canvas
canvas.requestPaint();
requestAnimationFrame(render);
}
requestAnimationFrame(render);
canvas.onpaint = (event) => {
if (event.changedElements && event.changedElements.length > 0) {
// Update the texture with drawElementImage, texElementImage2D, or copyElementImageToTexture, and update the CSS transform as shown in step 6
}
};
```
6. Update the CSS transform.
- For the 2D context case, apply the transform returned by the rendering call to the `style.transform` property:
```js
canvas.onpaint = () => {
ctx.reset();
// Draw the form element at x:0, y:0
let transform = ctx.drawElementImage(form_element, 0, 0);
// Sync the DOM location with the drawn location
form_element.style.transform = transform.toString();
};
```
- For the 3D case with WebGL or WebGPU, the browser needs to map from the 3D coordinate space into the CSS coordinate space using a viewport transform. To facilitate this, do the following:
- Convert WebGL MVP Matrix to DOM Matrix.
- Normalize the HTML element. HTML elements are sized in pixels (for example, 200px wide). WebGL, however, usually treats objects as "unit squares", for example, ranging from 0 to 1. If you don't normalize, your 200px button will look 200 times larger.
- Map to the canvas viewport. This step is the "re-scaling" phase: it stretches that unit-space math back out to match the actual pixel dimensions of your `<canvas>` element on the screen. It also flips the Y-axis, because in WebGL, up is positive, but in CSS, down is positive.
- Calculate the final transform. Multiply the matrices in order: Viewport _ MVP _ Normalization. Combining them into one final transform produces a "map" that tells the browser exactly where that HTML element layer should sit to align with the 3D drawing.
- Apply the transform to the HTML element. This moves the HTML element layer to sit directly on top of its rendered pixels. This ensures that when a user clicks a button or selects text, they are actually hitting the real HTML element.
```js
if (canvas.getElementTransform) {
// 1. Convert WebGL MVP Matrix to DOM Matrix
const mvpDOM = new DOMMatrix(Array.from(htmlElementMVP));
// 2. Normalize the HTML element (Canvas Grid pixels -> WebGL Model Space)
const dprX = canvas.width / canvas.clientWidth;
const dprY = canvas.height / canvas.clientHeight;
const gridWidth = targetHTMLElement.offsetWidth * dprX;
const gridHeight = targetHTMLElement.offsetHeight * dprY;
const toGLModel = new DOMMatrix()
// Scale pixels to 1 unit, flip Y (as in CSS it points down, and in WebGL it points up)
.scale(1 / gridWidth, -1 / gridHeight, 1 / gridHeight)
// Center the origin: (0,0) becomes (-width/2, -height/2) before scaling
.translate(-gridWidth / 2, -gridHeight / 2);
// 3. Map to the canvas viewport
const clipToCanvasViewport = new DOMMatrix()
// Move center (0,0) to center of canvas
.translate(canvas.width / 2, canvas.height / 2)
// Scale normalized clip (-1..1) to viewport size
.scale(canvas.width / 2, -canvas.height / 2, canvas.height / 2);
// 4. Multiply: (Clip -> Pixels) * (MVP) * (pixels -> unit square)
const screenSpaceTransform = clipToCanvasViewport
.multiply(mvpDOM)
.multiply(toGLModel);
// 5. Apply to the transform
const computedTransform = canvas.getElementTransform(
targetHTMLElement,
screenSpaceTransform,
);
targetHTMLElement.style.transform = computedTransform.toString();
}
```
7. [Troubleshooting] If the developer is experiencing a mismatch in the DOM logical layout in 3D even after applying the CSS transform from step 5, check if the developer is experiencing the issue in Chromium 148 or earlier. If that's the case, check if `transform.is2D` is correctly set to false for a 3D DOMMatrix. If not, re-initialize the DOMMatrix which corrects `is2D` to be false before applying the transform to the target HTML element. This issue is fixed in Chromium 149+, and if the developer is experiencing it in newer Chromium versions, the is2D value is not the cause:
```js
if (transform.is2D) {
// Workaround for Chromium bug https://crbug.com/512171941
// affecting Chrome versions under 149 where `transform.is2D`
// is incorrectly true for a 3D DOMMatrix. The assignment
// below re-initializes the DOMMatrix which corrects is2D to be false.
transform = DOMMatrix.fromFloat64Array(transform.toFloat64Array());
}
targetHTMLElement.style.transform = computedTransform.toString();
```
## Example code
### 2D Canvas
```html
<canvas id="canvas" layoutsubtree style="width: 400px; height: 200px;">
<div id="ui-element">
<p>
This text is rendered inside the canvas but is present in the DOM tree.
</p>
<input type="email" name="email" placeholder="enter your email" />
<button type="button">Submit</button>
</div>
</canvas>
<script>
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const uiElement = document.getElementById("ui-element");
canvas.onpaint = () => {
ctx.reset();
// Draw the HTML element at x:0, y:0
const transform = ctx.drawElementImage(uiElement, 0, 0);
// Sync the DOM location with the drawn location
uiElement.style.transform = transform.toString();
};
// Handle resizing to match device pixels
const observer = new ResizeObserver(([entry]) => {
const dpc = entry.devicePixelContentBoxSize;
canvas.width = dpc
? dpc[0].inlineSize
: Math.round(entry.contentRect.width * window.devicePixelRatio);
canvas.height = dpc
? dpc[0].blockSize
: Math.round(entry.contentRect.height * window.devicePixelRatio);
canvas.requestPaint();
});
const supportsDevicePixelContentBox =
typeof ResizeObserverEntry !== "undefined" &&
"devicePixelContentBoxSize" in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox
? { box: "device-pixel-content-box" }
: {};
observer.observe(canvas, options);
</script>
```
### WebGL Canvas
```html
<canvas id="canvas" layoutsubtree style="width: 400px; height: 400px;">
<div id="ui-element">
<p>WebGL UI Element</p>
<button>Action</button>
</div>
</canvas>
<script>
const canvas = document.getElementById("canvas");
const gl = canvas.getContext("webgl");
const uiElement = document.getElementById("ui-element");
// Setup WebGL texture...
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
canvas.onpaint = () => {
// 1. Update texture with HTML content
if (gl.texElementImage2D) {
try {
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, uiElement);
} catch (err) {
console.error('texElementImage2D copy failed:', err);
}
}
// ... Render your 3D scene here, calculating htmlElementMVP matrix ...
// 2. Sync DOM position with 3D scene
if (canvas.getElementTransform) {
const mvpDOM = new DOMMatrix(Array.from(htmlElementMVP));
// Recalculate the DPR compensation mapping
const dprX = canvas.width / canvas.clientWidth;
const dprY = canvas.height / canvas.clientHeight;
const gridWidth = uiElement.offsetWidth * dprX;
const gridHeight = uiElement.offsetHeight * dprY;
const cssToUnitSpace = new DOMMatrix()
.scale(1 / gridWidth, -1 / gridHeight, 1 / gridHeight)
.translate(-gridWidth / 2, -gridHeight / 2);
const clipToCanvasViewport = new DOMMatrix()
.translate(canvas.width / 2, canvas.height / 2)
.scale(canvas.width / 2, -canvas.height / 2, canvas.height / 2);
const screenSpaceTransform = clipToCanvasViewport
.multiply(mvpDOM)
.multiply(cssToUnitSpace);
const computedTransform = canvas.getElementTransform(
uiElement,
screenSpaceTransform,
);
uiElement.style.transform = computedTransform.toString();
}
};
</script>
```
### WebGPU Canvas
```html
<canvas id="canvas" layoutsubtree style="width: 400px; height: 400px;">
<div id="ui-element">
<p>WebGPU UI Element</p>
</div>
</canvas>
<script>
const canvas = document.getElementById("canvas");
const context = canvas.getContext("webgpu");
const uiElement = document.getElementById("ui-element");
// Setup WebGPU...
// const device = ...
// const targetTexture = ...
canvas.onpaint = () => {
// 1. Copy HTML content to texture
if (device.queue.copyElementImageToTexture) {
try {
const sourceDict = { source: uiElement };
const destDict = {
destination: { texture: targetTexture },
width: width,
height: height,
};
device.queue.copyElementImageToTexture(sourceDict, destDict);
} catch (err) {
console.error('copyElementImageToTexture copy failed:', err);
}
}
// 2. Sync DOM position (same matrix math as WebGL)
if (canvas.getElementTransform) {
const mvpDOM = new DOMMatrix(Array.from(htmlElementMVP));
// Recalculate the DPR compensation mapping
const dprX = canvas.width / canvas.clientWidth;
const dprY = canvas.height / canvas.clientHeight;
const gridWidth = uiElement.offsetWidth * dprX;
const gridHeight = uiElement.offsetHeight * dprY;
const cssToUnitSpace = new DOMMatrix()
.scale(1 / gridWidth, -1 / gridHeight, 1 / gridHeight) // Retain Z scale
.translate(-gridWidth / 2, -gridHeight / 2);
const clipToCanvasViewport = new DOMMatrix()
.translate(canvas.width / 2, canvas.height / 2)
.scale(canvas.width / 2, -canvas.height / 2, canvas.height / 2); // Retain Z scale
const screenSpaceTransform = clipToCanvasViewport
.multiply(mvpDOM)
.multiply(cssToUnitSpace);
const computedTransform = canvas.getElementTransform(
uiElement,
screenSpaceTransform,
);
uiElement.style.transform = computedTransform.toString();
}
};
</script>
```
## Best Practices
- **MANDATORY**: Check browser support for the HTML-in-Canvas API before using it.
- **MANDATORY**: Always add the `layoutsubtree` attribute to the `<canvas>` element.
- **MANDATORY**: Use an `onpaint` event handler to render the HTML content to the canvas.
- **MANDATORY**: Use the `drawElementImage`, `texElementImage2D`, or `copyElementImageToTexture` methods to render the HTML content to the canvas.
- **MANDATORY**: Update the CSS transform of the HTML element to match the transform of the rendered content by setting the `style.transform` property of the HTML element.
- **MANDATORY**: Use `ResizeObserver` to observe the screen size and update the canvas size to match device pixels.
- **DO NOT** embed cross-origin content in a canvas, as it is not supported.
- **DO NOT** initialize `ResizeObserver` within the `onpaint` event handler, as it may lead to memory leaks.
## Fallback strategies
HTML in canvas is not natively supported by any major browser yet.
The HTML-in-Canvas API is not currently supported in all modern browsers, thus a fallback strategy is typically required.
However, given the improved performance benefits of this API, HTML-in-Canvas should be used if the browser supports it.
The fallback strategy depends on the use case. For example, for an interactive HTML content in canvas, if HTML-in-Canvas is not supported, place the HTML content on top of the canvas using CSS.
### HTML-in-Canvas polyfills
Use the following polyfill script to mimic the HTML-in-Canvas API in browsers that do not support it.
1. Install or embed the library:
```
# Install
npm install three-html-render
```
```
# Embed
<script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>
```
2. Run the `installHtmlInCanvasPolyfill()` method to translate HTML-in-Canvas.
guides/visual-design/improve-text-layout-and-legibility.md
# Improve Text Layout and Legibility
The layout of text, particularly at the ends of lines and ends of paragraphs, can impact the legibility and aesthetic appeal of a page. CSS provides several text wrapping options that can improve specific use cases.
For short text blocks like headings, use `text-wrap: balance`. This property instructs the browser to distribute text as evenly as possible across lines, creating a more symmetrical appearance.
The `text-wrap: pretty` CSS property allows you to improve the typographic quality of body text by enabling a more sophisticated wrapping algorithm. It is specifically designed to prevent "orphans" (single words on the last line of a paragraph) and create a more pleasing visual "rag" for long blocks of text.
## Implementation
### 1. **Identify text elements**:
For `text-wrap: balance`, select short text blocks like headings and table headers. Avoid elements that have visible boxes such as borders or backgrounds, as this can create unexpected visually empty areas in the layout.
For `text-wrap: pretty`, select elements potentially containing long runs of text where orphaned words (runts) or poor line breaks are most noticeable. This includes the following elements:
- `<p>`
- `<blockquote>`
- `<li>`
- Any other element potentially containing long runs of text.
#### Choosing the Right Wrapping Method
| Criteria | `text-wrap: balance` | `text-wrap: pretty` | `text-wrap: wrap` (Default) |
| :--- | :--- | :--- | :--- |
| **Best For** | Short blocks (Headings, Titles) | Long blocks (Paragraphs, Lists) | Performance-critical content |
| **Visual Goal** | Symmetrical line lengths | Avoiding orphans ("runts") | Fast, standard wrapping |
| **Line Constraints** | Up to 6–10 lines (algorithm limit) | Best for 3 to many lines | No limit |
| **Perf Cost** | **High**: Binary search algorithm | **Medium**: Look-back algorithm | **Low**: Standard greedy algorithm |
### 2. **Apply the chosen wrapping**:
Apply `text-wrap: balance` specifically to short, multi-line elements such as headings (`h1`-`h6`), subheadings, or pullquotes.
```css
/* Target specific heading elements for balanced wrapping */
h1, h2, h3, h4, h5, h6 {
/* Enables balanced line-breaking logic */
text-wrap: balance;
}
```
Use `text-wrap: pretty` to enable an optimized algorithm that evaluates the last few lines of a paragraph to find the best break points.
```css
/* Apply to multi-line text blocks to prevent orphaned words */
p, blockquote, li, .pretty-text {
/* Enables pretty line-breaking logic for body copy */
text-wrap: pretty;
}
```
### Critical Constraints and Performance
#### text-wrap: balance
* **Line Limit:** Browsers impose a limit on the number of lines they will attempt to balance to maintain performance (typically **6 lines** in Chromium and **10 lines** in Firefox). If the text exceeds this limit, the browser reverts to standard `wrap` behavior. Avoid using `text-wrap: balance` on text blocks that are likely to exceed these limits.
* **Targeted Application:** DO NOT apply `text-wrap: balance` globally (e.g., `* { text-wrap: balance; }`). The iterative "binary search" algorithm used by browsers is computationally expensive. Limit its use to specific, short text elements.
* **Interaction with Width:** `text-wrap: balance` does not change the container's width (`inline-size`). It only affects how text wraps *within* that width. This can leave empty space at the end of the container, which may affect layouts relying on full-width text blocks.
#### text-wrap: pretty
* **Performance vs. Quality**: MANDATORY: `text-wrap: pretty` is more computationally expensive than the default `wrap` (greedy) algorithm because it evaluates multiple lines (typically the last four) to optimize the break points. Avoid applying it globally to every element if your page has an extreme amount of text content.
* **Best for multi-line text**: The benefits of `pretty` are most apparent in paragraphs of three or more lines. It has little to no effect on short, single-line text.
* **Browser-specific behavior**: Be aware that implementation details vary. Chromium-based browsers typically focus on the last four lines, while other engines may evaluate the entire paragraph.
### Fallback strategies
Baseline status for text-wrap: balance: Newly available. It's been Baseline since 2024-05-13.
Supported by: Chrome 114 (May 2023), Edge 114 (Jun 2023), Firefox 121 (Dec 2023), and Safari 17.5 (May 2024).
text-wrap: pretty has limited availability.
Supported by: Chrome 117 (Sep 2023), Edge 117 (Sep 2023), and Safari 26 (Sep 2025).
Unsupported in: Firefox.
In browsers that do not support `text-wrap: balance` or `text-wrap: pretty`, the property is ignored, and the text will wrap using the default `wrap` behavior. This is a progressive enhancement that gracefully degrades to standard typography. This ensures that your content remains perfectly readable across all browsers while providing a superior experience to those that support it.
For critical layouts where refined text layout is a requirement, use a JavaScript library, but be aware that this may be slow and cause performance issues.guides/visual-design/interactive-content-in-3d-scenes.md
# Enable interactive HTML content in 3D scenes
The HTML-in-Canvas API allows rendering real DOM directly inside a canvas element. When applied to 3D rendering contexts like WebGL, WebGPU, or Three.js, adding the `layoutsubtree` attribute enables descendant HTML elements to be seamlessly projected into the 3D scene. Crucially, because the HTML elements remain part of the active DOM layout tree, they retain full interactivity—allowing users to click buttons, select text, and trigger focus states natively without requiring complex raycasting or custom event handling.
## How to implement
### WebGL and WebGPU
When using WebGL or WebGPU, follow these steps:
1. Check if HTML-in-Canvas is supported in the browser:
```
if ('requestPaint' in HTMLCanvasElement.prototype) {
// Use HTML in Canvas API
} else {
// Use fallback strategy
}
```
2. Initialize `<canvas>` to support descendant HTML elements by adding the `layoutsubtree` attribute to the `<canvas>` HTML element. Place your HTML content inside the `<canvas>` element with the `layoutsubtree` attribute.
```html
<canvas id="canvas" layoutsubtree>
<div id="html-content"></div>
</canvas>
```
3. Scale your canvas grid to match the device scale factor to prevent blurriness:
```js
const observer = new ResizeObserver(([entry]) => {
const dpc = entry.devicePixelContentBoxSize;
canvas.width = dpc
? dpc[0].inlineSize
: Math.round(entry.contentRect.width * window.devicePixelRatio);
canvas.height = dpc
? dpc[0].blockSize
: Math.round(entry.contentRect.height * window.devicePixelRatio);
});
const supportsDevicePixelContentBox =
typeof ResizeObserverEntry !== "undefined" &&
"devicePixelContentBoxSize" in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox
? { box: "device-pixel-content-box" }
: {};
observer.observe(canvas, options);
```
4. Render the HTML content to the canvas inside a `canvas.onpaint` event handler:
- In WebGL context, use the `texElementImage2D` method:
```js
canvas.onpaint = () => {
if (gl.texElementImage2D) {
try {
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, uiElement);
} catch (err) {
console.error('texElementImage2D copy failed:', err);
}
}
};
```
- In WebGPU context, use the `copyElementImageToTexture` method:
```js
canvas.onpaint = () => {
if (root.device.queue.copyElementImageToTexture) {
try {
const sourceDict = { source: valueElement };
const destDict = {
destination: { texture: targetTexture },
width: 512,
height: 128,
};
root.device.queue.copyElementImageToTexture(sourceDict, destDict);
} catch (err) {
console.error('copyElementImageToTexture copy failed:', err);
}
}
};
```
When using a `requestAnimationFrame` loop to render the scene, call `canvas.requestPaint()` within the loop to ensure that the HTML content is rendered to the canvas. Make sure you only re-render the canvas if there has been an update to the descendant HTML elements:
```js
function render() {
// Request to update the canvas
canvas.requestPaint();
requestAnimationFrame(render);
}
requestAnimationFrame(render);
canvas.onpaint = (event) => {
if (event.changedElements && event.changedElements.length > 0) {
// Update the texture with texElementImage2D, and update the CSS transform as shown in step 6
}
};
```
6. Update the CSS transform.
The browser needs to map from the 3D coordinate space into the CSS coordinate space using a viewport transform. To facilitate this, do the following:
- Convert the MVP Matrix to DOM Matrix.
- Normalize the HTML element. HTML elements are sized in pixels (for example, 200px wide). WebGL, however, usually treats objects as "unit squares", for example, ranging from 0 to 1. If you don't normalize, your 200px button will look 200 times larger.
- Map to the canvas viewport. This step is the "re-scaling" phase: it stretches that unit-space math back out to match the actual pixel dimensions of your `<canvas>` element on the screen. It also flips the Y-axis, because in WebGL, up is positive, but in CSS, down is positive.
- Calculate the final transform. Multiply the matrices in order: Viewport * MVP * Normalization. Combining them into one final transform produces a "map" that tells the browser exactly where that HTML element layer should sit to align with the 3D drawing.
- Apply the transform to the HTML element. This moves the HTML element layer to sit directly on top of its rendered pixels. This ensures that when a user clicks a button or selects text, they are actually hitting the real HTML element.
```js
if (canvas.getElementTransform) {
// 1. Convert WebGL MVP Matrix to DOM Matrix
const mvpDOM = new DOMMatrix(Array.from(htmlElementMVP));
// 2. Normalize the HTML element (Canvas Grid pixels -> WebGL Model Space)
const dprX = canvas.width / canvas.clientWidth;
const dprY = canvas.height / canvas.clientHeight;
const gridWidth = targetHTMLElement.offsetWidth * dprX;
const gridHeight = targetHTMLElement.offsetHeight * dprY;
const toGLModel = new DOMMatrix()
// Scale pixels to 1 unit, flip Y (as in CSS it points down, and in WebGL it points up)
.scale(1 / gridWidth, -1 / gridHeight, 1 / gridHeight)
// Center the origin: (0,0) becomes (-width/2, -height/2) before scaling
.translate(-gridWidth / 2, -gridHeight / 2);
// 3. Map to the canvas viewport
const clipToCanvasViewport = new DOMMatrix()
// Move center (0,0) to center of canvas
.translate(canvas.width / 2, canvas.height / 2)
// Scale normalized clip (-1..1) to viewport size
.scale(canvas.width / 2, -canvas.height / 2, canvas.height / 2);
// 4. Multiply: (Clip -> Pixels) * (MVP) * (pixels -> unit square)
const screenSpaceTransform = clipToCanvasViewport
.multiply(mvpDOM)
.multiply(toGLModel);
// 5. Apply to the transform
const computedTransform = canvas.getElementTransform(
targetHTMLElement,
screenSpaceTransform,
);
targetHTMLElement.style.transform = computedTransform.toString();
}
```
7. [Troubleshooting] If the developer is experiencing a mismatch in the DOM logical layout in 3D even after applying the CSS transform from step 5, check if the developer is experiencing the issue in Chromium 148 or earlier. If that's the case, check if `transform.is2D` is correctly set to false for a 3D DOMMatrix. If not, re-initialize the DOMMatrix which corrects `is2D` to be false before applying the transform to the target HTML element. This issue is fixed in Chromium 149+, and if the developer is experiencing it in newer Chromium versions, the is2D value is not the cause:
```js
if (transform.is2D) {
// Workaround for Chromium bug https://crbug.com/512171941
// affecting Chrome versions under 149 where `transform.is2D`
// is incorrectly true for a 3D DOMMatrix. The assignment
// below re-initializes the DOMMatrix which corrects is2D to be false.
transform = DOMMatrix.fromFloat64Array(transform.toFloat64Array());
}
targetHTMLElement.style.transform = computedTransform.toString();
```
### Three.js
1. Check if HTML-in-Canvas is supported in the browser:
```
if ('requestPaint' in HTMLCanvasElement.prototype) {
// Use HTML in Canvas API
} else {
// Use fallback strategy
}
```
2. Create a custom geometry and material for the HTML content.
3. Pass the DOM element into THREE.HTMLTexture:
```js
material.map = new THREE.HTMLTexture(element);
mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );
```
## Example code
### WebGL Canvas
```html
<canvas id="canvas" layoutsubtree style="width: 400px; height: 400px;">
<div id="ui-element">
<p>WebGL UI Element</p>
<button>Action</button>
</div>
</canvas>
<script>
const canvas = document.getElementById("canvas");
const gl = canvas.getContext("webgl");
const uiElement = document.getElementById("ui-element");
// Setup WebGL texture...
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
canvas.onpaint = () => {
// 1. Update texture with HTML content
if (gl.texElementImage2D) {
try {
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, uiElement);
} catch (err) {
console.error('texElementImage2D copy failed:', err);
}
}
// ... Render your 3D scene here, calculating htmlElementMVP matrix ...
// 2. Sync DOM position with 3D scene
if (canvas.getElementTransform) {
const mvpDOM = new DOMMatrix(Array.from(htmlElementMVP));
// Recalculate the DPR compensation mapping
const dprX = canvas.width / canvas.clientWidth;
const dprY = canvas.height / canvas.clientHeight;
const gridWidth = uiElement.offsetWidth * dprX;
const gridHeight = uiElement.offsetHeight * dprY;
const cssToUnitSpace = new DOMMatrix()
.scale(1 / gridWidth, -1 / gridHeight, 1 / gridHeight)
.translate(-gridWidth / 2, -gridHeight / 2);
const clipToCanvasViewport = new DOMMatrix()
.translate(canvas.width / 2, canvas.height / 2)
.scale(canvas.width / 2, -canvas.height / 2, canvas.height / 2);
const screenSpaceTransform = clipToCanvasViewport
.multiply(mvpDOM)
.multiply(cssToUnitSpace);
const computedTransform = canvas.getElementTransform(
uiElement,
screenSpaceTransform,
);
uiElement.style.transform = computedTransform.toString();
}
};
</script>
```
### WebGPU Canvas
```html
<canvas id="canvas" layoutsubtree style="width: 400px; height: 400px;">
<div id="ui-element">
<p>WebGPU UI Element</p>
</div>
</canvas>
<script>
const canvas = document.getElementById("canvas");
const context = canvas.getContext("webgpu");
const uiElement = document.getElementById("ui-element");
// Setup WebGPU...
// const device = ...
// const targetTexture = ...
canvas.onpaint = () => {
// 1. Copy HTML content to texture
if (device.queue.copyElementImageToTexture) {
try {
const sourceDict = { source: uiElement };
const destDict = {
destination: { texture: targetTexture },
width: width,
height: height,
};
device.queue.copyElementImageToTexture(sourceDict, destDict);
} catch (err) {
console.error('copyElementImageToTexture copy failed:', err);
}
}
// 2. Sync DOM position (same matrix math as WebGL)
if (canvas.getElementTransform) {
const mvpDOM = new DOMMatrix(Array.from(htmlElementMVP));
// Recalculate the DPR compensation mapping
const dprX = canvas.width / canvas.clientWidth;
const dprY = canvas.height / canvas.clientHeight;
const gridWidth = uiElement.offsetWidth * dprX;
const gridHeight = uiElement.offsetHeight * dprY;
const cssToUnitSpace = new DOMMatrix()
.scale(1 / gridWidth, -1 / gridHeight, 1 / gridHeight) // Retain Z scale
.translate(-gridWidth / 2, -gridHeight / 2);
const clipToCanvasViewport = new DOMMatrix()
.translate(canvas.width / 2, canvas.height / 2)
.scale(canvas.width / 2, -canvas.height / 2, canvas.height / 2); // Retain Z scale
const screenSpaceTransform = clipToCanvasViewport
.multiply(mvpDOM)
.multiply(cssToUnitSpace);
const computedTransform = canvas.getElementTransform(
uiElement,
screenSpaceTransform,
);
uiElement.style.transform = computedTransform.toString();
}
};
</script>
```
### Three.js
```js
// 1. Initialize Three.js camera, scene, renderer, mesh, interactions;
// 2. Ensure HTML-in-Canvas feature support
if (!('requestPaint' in HTMLCanvasElement.prototype)) {
// Use a fallback strategy
}
// 3. Initialize the source HTML DOM element
const element = document.createElement('div');
element.innerHTML = '<h1>Hello World</h1>';
// 4. Create geometry and material
const geometry = new RoundedBoxGeometry( 100, 100, 100, 10, 10 );
const material = new THREE.MeshStandardMaterial( { roughness: 0, metalness: 0.5 } );
// 5. Pass the DOM element into THREE.HTMLTexture
material.map = new THREE.HTMLTexture(element);
mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );
// 6. Render Loop
function animate() {
renderer.render(scene, camera);
}
```
## Best Practices
- **MANDATORY**: Check browser support for the HTML-in-Canvas API before using it.
- **MANDATORY**: When using WebGL or WebGPU, always add the `layoutsubtree` attribute to the `<canvas>` element.
- **MANDATORY**: When using WebGL or WebGPU, use an `onpaint` event handler to render the HTML content to the canvas.
- **MANDATORY**: Use the `texElementImage2D` for WebGL, or `copyElementImageToTexture` for WebGPU methods to render the HTML content to the canvas.
- **MANDATORY**: When using WebGL or WebGPU, update the CSS transform of the HTML element to match the transform of the rendered content by setting the `style.transform` property of the HTML element.
- **MANDATORY**: Observe the screen size and update the canvas size to match device pixels, for example, by using `ResizeObserver`.
- **DO NOT** embed cross-origin content in a canvas, as it is not supported.
- **DO NOT** initialize `ResizeObserver` within the `onpaint` event handler, as it may lead to memory leaks.
### Fallback strategies
HTML in canvas is not natively supported by any major browser yet.
The HTML-in-Canvas API is not currently supported in all modern browsers, thus a fallback strategy is typically required.
However, given the improved performance benefits of this API, HTML-in-Canvas should be used if the browser supports it.
The fallback strategy depends on the use case. For example, for an interactive HTML content in canvas, if HTML-in-Canvas is not supported, place the HTML content on top of the canvas using CSS.
### HTML-in-Canvas polyfill
Use the following polyfill script to mimic the HTML-in-Canvas API in browsers that do not support it.
1. Install or embed the library:
```
# Install
npm install three-html-render
```
```
# Embed
<script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>
```
2. Run the `installHtmlInCanvasPolyfill()` method to translate HTML-in-Canvas.
guides/visual-design/precise-text-alignment.md
# Precise Text Alignment
## The Problem
Browsers automatically add extra whitespace above and below text characters to accommodate line-height and font-specific metrics like ascenders and descenders. This "ghost space" makes it impossible to achieve pixel-perfect vertical alignment using standard CSS.
Common issues include:
- **Misaligned Icons**: Text appears visually lower or higher than an adjacent icon even when using `align-items: center`.
- **Inaccurate Padding**: A button with `padding: 12px` visually appears to have more space on top or bottom because of the font's internal leading.
- **Flush Alignment**: You cannot align the top of a capital letter exactly with the top of a container or an adjacent image without using "magic number" negative margins.
## The Solution
The `text-box-trim` and `text-box-edge` properties (shorthand `text-box`) allow you to trim this internal leading based on specific font metrics. By trimming the text box to the **cap-height** (top of capital letters) and the **alphabetic baseline** (bottom of most letters), you can ensure that the element's bounding box matches its visual content.
### Implementation Strategy
1. **MANDATORY**: Apply `text-box-trim: trim-both` (or the `text-box` shorthand) to the element containing the text.
2. **MANDATORY**: Specify which metrics to use for trimming with `text-box-edge`. For most UI alignment, use `cap alphabetic`.
3. **DO** use this to achieve visual vertical centering in flex or grid containers.
4. **DO** use it to ensure that your CSS `padding` values match the visual gap between the text and the container edge.
5. **DO NOT** use it on long-form body text where traditional line-spacing is necessary for readability. It is best suited for headings, buttons, and UI labels.
## Implementation Guide
### Use case 1: Trim internal leading for badges
Different fonts have different amounts of built-in spacing above and below the text. This can provide challenges in matching a design, or in visually centering text in a badge. When you want a container's padding to exactly hug the text, use `text-box: trim-both cap alphabetic`. This is especially useful for dense UI components like badges or tags. This allows the padding to start right at the text edge on all sides.
```css
.badge {
padding: 10px;
background: hotpink;
border-radius: 10px;
/*
Trims the top to the cap-height and
the bottom to the alphabetic baseline.
*/
text-box: trim-both cap alphabetic;
}
```
### Use case 2: Center text with icons
When using Flexbox to align text and icons, the "ghost space" often makes the text look slightly off-center. Trimming the box ensures the layout engine uses the actual visible letter height for alignment.
```css
.button {
display: inline-flex;
align-items: center;
gap: 8px;
}
/*
text-box does NOT inherit, and must be applied directly to the text element.
*/
.button-text{
/*
The flex container now centers against the
visible letters, not the invisible font box.
*/
text-box: trim-both cap alphabetic;
}
```
### Use case 3: Align text flush with top edges
To align a heading perfectly with the top of an adjacent image or decorative element, use `trim-start cap alphabetic`. Even though the end will not be trimmed, the end edge must be defined.
```css
.hero {
display: flex;
align-items: flex-start;
}
h1 {
/* MANDATORY: The bottom edge must also be defined, even though only the top is trimmed. */
text-box: trim-start cap alphabetic;
}
```
## Best Practices
- **DO** use the `text-box` shorthand for conciseness: `text-box: <trim-direction> <edges>`.
- **DO** always specify both edges, even if only one edge is being trimmed (unless using the default `text` edge).
- **DO** combine with `line-height` for controlled spacing. Trimming removes the leading before the first and last line of text, but `line-height` still affects the distance between lines in multi-line text.
- **DO NOT** apply to every element. Use it only where precision alignment is a requirement.
### Fallback strategies
text-box has limited availability.
Supported by: Chrome 133 (Feb 2025), Edge 133 (Feb 2025), and Safari 18.2 (Dec 2024).
Unsupported in: Firefox.
`text-box` is a progressive enhancement. In browsers that do not support it, the text will simply render with its default leading. Your layout will still be functional, though slightly less precise. No special fallback code is required as the properties are safely ignored by older browsers.
## Other Considerations
1. **Font Metrics**: Different fonts have different internal metrics. `cap alphabetic` is a reliable default, but some fonts or use cases may require `ex` (x-height) for better lowercase alignment.
2. **Multi-line Text**: Trimming applies to the first and last line of the block. Internal lines are not affected.
guides/visual-design/prevent-text-wrapping.md
# Prevent text wrapping
Modern CSS provides the `text-wrap` property to control how text breaks within its container. To ensure text stays on a single line and ignores container boundaries, use `text-wrap: nowrap`. This is the modern, more semantic replacement for `white-space: nowrap`.
Preventing text wrapping is useful for UI elements like navigation tabs, horizontal scrolling chips, or any scenario where a line break would break the layout or visual design.
## How to implement `text-wrap: nowrap`
### Basic Usage
To prevent any automatic line breaks, apply `text-wrap: nowrap` to the element containing the text.
1. **MANDATORY**: Apply `text-wrap: nowrap` to the target element.
2. **OPTIONAL**: Use an `overflow` property (such as `hidden`, `scroll`, or `auto`) to manage the resulting overflow.
3. **OPTIONAL**: Use `text-overflow: ellipsis` to provide a visual cue when text is truncated. Note: This requires `overflow: hidden`.
### Example code
```css
.no-wrap-text {
/* MANDATORY: Prevents automatic line breaks */
text-wrap: nowrap;
/* OPTIONAL: Handles the overflow visually */
overflow: hidden;
text-overflow: ellipsis;
/* OPTIONAL: Constrain width to force and handle overflow within this element */
max-width: 200px;
}
```
### Specific Control with Longhands
The `text-wrap` property is a shorthand for `text-wrap-mode` (whether text wraps) and `text-wrap-style` (how it wraps). Since `text-wrap-style` is ignored when wrapping is disabled, you should generally stick to the `text-wrap` shorthand.
While the longhand `text-wrap-mode: nowrap` exists, the property name is currently considered a placeholder by the CSS Working Group and may change in the future.
```css
.granular-control {
/* Modern longhand equivalent to white-space: nowrap */
/* Preferred: text-wrap: nowrap; */
text-wrap-mode: nowrap;
}
```
### Fallback strategies
Baseline status for text-wrap: Newly available. It's been Baseline since 2024-10-17.
Supported by: Chrome 130 (Oct 2024), Edge 130 (Oct 2024), Firefox 124 (Mar 2024), and Safari 17.5 (May 2024).
For browsers that do not yet support `text-wrap`, use the legacy `white-space` property. Modern browsers treat `white-space` as a shorthand for setting both the `text-wrap-mode` and `white-space-collapse` properties.
```css
.no-wrap-with-fallback {
/* Fallback for older browsers */
white-space: nowrap;
/* Modern standard */
text-wrap: nowrap;
}
```
guides/visual-design/shaped-cutouts.md
# Shaped Cutouts
## Overview
CSS Masking allows you to clip an element to a custom shape, such as adding a notch to a card or creating a shaped border. When combining shapes for complex layouts, choose your masking strategy based on the type of content the element contains:
| Masking strategy | Best For | Text Impact |
| ------------------------------- | ------------------------------- | ------------------------------ |
| Direct Element SVG Masking | Images, Icons, Decorative shapes, Complex shapes | Not recommended (can crop text) |
| Adjacent Element SVG Masking | Cards with Text, Crucial content | Text remains fully readable |
| Pure CSS Gradients | Simple Geometric Shapes | Not recommended (can crop text) |
---
## Implementation
To implement shaped cutouts:
### Using an SVG Mask
SVG masks allow you to define shapes that subtract from or add to the visible area using white (reveal) and black (hide) fills.
> **Luminance vs. Alpha Masking**: SVG masks default to **luminance** (brightness) mode, which is why we use `fill="white"` to reveal areas and `fill="black"` to cut them out. If you prefer to use the SVG's transparency (alpha channel) instead of colors, you can set `mask-type: alpha;` in CSS or `mask-type="alpha"` on the SVG `<mask>` element.
#### Direct Element SVG Masking
When there is no text inside the element to worry about (such as profile avatars, product images, or illustrations), you can apply the SVG mask directly to the element.
Using `maskContentUnits="objectBoundingBox"` inside the SVG mask ensures that the mask scales automatically to the width and height of any element it is applied to.
```html
<!-- 1. Define the mask in SVG (hidden from view) -->
<svg width="0" height="0" style="position: absolute;" aria-hidden="true">
<defs>
<!-- objectBoundingBox makes the mask scale from 0 to 1 along the element's borders -->
<mask id="splat-mask" maskContentUnits="objectBoundingBox">
<!-- Organic Bezier path defining an artistic paint splat shape -->
<path d="M 0.5 0.05 C 0.58 0.05, 0.58 0.21, 0.67 0.18 C 0.76 0.15, 0.79 0.08, 0.85 0.15 C 0.91 0.22, 0.83 0.32, 0.89 0.38 C 0.95 0.44, 1.03 0.45, 0.99 0.55 C 0.95 0.65, 0.82 0.62, 0.8 0.72 C 0.78 0.82, 0.89 0.92, 0.8 0.97 C 0.71 1.02, 0.64 0.88, 0.55 0.93 C 0.46 0.98, 0.44 1.06, 0.35 1.01 C 0.26 0.96, 0.31 0.81, 0.22 0.79 C 0.13 0.77, 0.01 0.88, 0.01 0.77 C 0.01 0.66, 0.16 0.62, 0.14 0.52 C 0.12 0.42, -0.02 0.39, 0.03 0.29 C 0.08 0.19, 0.23 0.27, 0.29 0.19 C 0.35 0.11, 0.3 0.01, 0.4 0.01 C 0.5 0.01, 0.42 0.05, 0.5 0.05 Z" fill="white" />
</mask>
</defs>
</svg>
<!-- 2. Apply the mask to the image element -->
<img src="avatar.jpg" alt="User Profile" class="shaped-avatar">
<style>
.shaped-avatar {
width: 200px;
height: 200px;
object-fit: cover;
/* Apply the SVG mask ID with standard and webkit-prefixed properties */
-webkit-mask-image: url(#splat-mask);
mask-image: url(#splat-mask);
}
</style>
```
#### Adjacent Element SVG Masking
Apply the mask to an adjacent element rather than the parent element containing text. This ensures that text doesn't get clipped and remains readable.
To solve this, structure your component into a two-part layout:
1. A **safe, unmasked text container** (`.card-body`) for all readable content.
2. An **empty decorative `div`** (`.card-accent`) placed next to it, which receives the custom SVG mask.
By assigning both parts the same background color, they visually merge into a single, custom-shaped component.
```html
<!-- 1. Define the SVG mask (hidden from view) -->
<svg width="0" height="0" style="position: absolute;" aria-hidden="true">
<defs>
<!-- Use objectBoundingBox to make the mask scale with the element -->
<mask id="accent-stencil" maskContentUnits="objectBoundingBox">
<!-- Fill the entire area with white (fully visible) -->
<rect width="1" height="1" fill="white" />
<!-- Draw a black shape to cut out a concave curve. Extending the path to x=1.1 guarantees it fully clears the right edge, preventing subpixel lines -->
<path d="M 1.1,0 C 0.4,0.1 0.4,0.9 1.1,1 L 1.1,0 Z" fill="black" />
</mask>
</defs>
</svg>
<!-- 2. Create the unified card layout -->
<div class="unified-card">
<!-- The text-bearing element remains unmasked and perfectly rectangular -->
<div class="card-body">
<h3>Premium Membership</h3>
<p>Get exclusive weekly updates on modern web standards and premium UI designs.</p>
</div>
<!-- The empty accent element next to it is masked to form the custom shape -->
<div class="card-accent"></div>
</div>
<style>
.unified-card {
display: flex;
width: 400px;
}
.card-body {
flex: 1;
background-color: #1e293b; /* Elegant slate color */
color: #f8fafc;
padding: 24px;
border-top-left-radius: 16px;
border-bottom-left-radius: 16px;
}
.card-accent {
width: 60px;
background-color: #1e293b; /* Same background color so they merge visually without seams */
border-top-right-radius: 16px;
border-bottom-right-radius: 16px;
/* Reference the SVG mask */
-webkit-mask-image: url(#card-accent-mask);
mask-image: url(#card-accent-mask);
}
</style>
```
### Using a Single CSS Gradient for Simple Cutouts
When you only need simple geometric cutouts (such as a semi-circular top notch, side indentations, or straight diagonal cuts), you do not need to write or reference an external SVG. Instead, you can define a CSS radial or linear gradient directly inside the `mask-image` property.
```html
<div class="gradient-masked-card">
<h3>Notched Coupon Card</h3>
<p>This card uses a pure CSS radial gradient to cut out a semi-circular notch along its top edge.</p>
</div>
<style>
.gradient-masked-card {
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
color: white;
padding: 40px 24px 24px 24px; /* Extra top padding ensures content clears the notch */
border-radius: 16px;
text-align: center;
/* radial-gradient places a circle at 50% (horizontal center) and 0% (top edge), cutting out a 20px transparent notch */
-webkit-mask-image: radial-gradient(circle at 50% 0%, transparent 20px, black 21px);
mask-image: radial-gradient(circle at 50% 0%, transparent 20px, black 21px);
}
</style>
```
## Fallback strategies
Baseline status for Masks: Widely available. It's been Baseline since 2023-12-07.
Supported by: Chrome 120 (Dec 2023), Edge 120 (Dec 2023), Firefox 53 (Apr 2017), and Safari 15.4 (Mar 2022).
If a browser does not support `mask-image` or the prefixed version:
- **For Shaped Images**: The element will degrade gracefully, displaying as a normal rectangular element with its default fallback styles.
- **For Shaped Cards**: The adjacent decorative `.card-accent` element will remain a solid, unmasked rectangle. Since it shares the same background color as the text container, the entire card will simply render as a standard, clean rectangular container.
- **Progressive Enhancement**: By keeping text layers outside the mask entirely, your content is guaranteed to remain completely readable, structured, and accessible on older browsers.
guides/visual-design/soft-edge-content-fade.md
# Soft Edge Content Fade
## Overview
To apply a transparency gradient to the edges of a container (e.g., to indicate more content is available to scroll or to fade out text), use CSS Masking with a linear gradient. This approach is superior to using a semi-transparent overlay because it actually fades the content itself, allowing the background to show through naturally without interfering with text selection or pointer events.
## Implementation
To implement a soft edge fade:
### Fading the bottom edge of a container
This is useful for indicating that there is more content below in a scrollable area.
```css
.container {
/* Enable scrolling */
overflow-y: auto;
/* MANDATORY: Use vendor prefix for wider support in older browsers */
-webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
/* Standard property for modern browsers */
mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
}
```
### Fading both top and bottom edges
You can use a single gradient with multiple color stops to fade both edges.
```css
.dual-fade-container {
/* Content is visible between 10% and 90% of the height */
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%);
}
```
## Fallback strategies
Baseline status for Masks: Widely available. It's been Baseline since 2023-12-07.
Supported by: Chrome 120 (Dec 2023), Edge 120 (Dec 2023), Firefox 53 (Apr 2017), and Safari 15.4 (Mar 2022).
If a browser does not support `mask-image` or the prefixed version:
- The content will not fade and will display with sharp edges.
- Ensure the interface is still functional and content is readable without the fade (progressive enhancement).
- You can use a semi-transparent overlay as a fallback, but be aware it requires knowing the background color and may interfere with text selection unless `pointer-events: none` is used.
```css
/* Fallback using an overlay for browsers that do not support masking */
@supports (not (mask-image: linear-gradient(to bottom, black, transparent))) and (not (-webkit-mask-image: linear-gradient(to bottom, black, transparent))) {
.container {
position: relative;
}
.container::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 20%;
/* Fallback assumes a solid background color (e.g., white) */
background: linear-gradient(to bottom, rgba(255,255,255,0), rgba(255,255,255,1));
pointer-events: none; /* Allow interaction with text underneath */
}
}
```
guides/visual-design/visually-stable-font-fallbacks.md
# Visually Stable Font Fallbacks
When web fonts load, they often replace a fallback font that has different dimensions, even if both are set to the same `font-size`. This causes "layout shift" (Cumulative Layout Shift) and can make text illegible if the fallback's lowercase letters (x-height) are significantly different than the preferred font.
The `font-size-adjust` property solves this by normalizing the size of the font based on a specific metric (usually the x-height), ensuring that text occupies the same visual space regardless of which font is currently active.
## Implementation steps
### 1. Identify the aspect ratio of your preferred font
To normalize fallbacks, you need the "aspect value" (the ratio of lowercase letters to the font size) of your primary font.
* **Automatic discovery (Recommended):** Use the `from-font` keyword to let the browser extract the ratio from your primary web font.
* **Manual calculation:** If you know the specific value (e.g., 0.545 for Verdana), you can provide it directly for more precise control.
### 2. Apply font-size-adjust to the text container
Apply the property to the element or a parent container. This ensures that if the primary font fails to load or is in the process of loading, the fallback font is scaled to match the visual size of the primary font.
```css
.text-content {
/* Define your font stack as usual */
font-family: "MyWebFont", "Arial", sans-serif;
font-size: 1rem;
/* MANDATORY: Normalize the font size based on the primary font's x-height.
This ensures that if 'Arial' is used as a fallback, it is scaled
to match the x-height of 'MyWebFont'. */
font-size-adjust: from-font;
}
```
### 3. (Optional) Adjust for specific metrics
While x-height is the default and most common, you can normalize by other metrics like `cap-height` (useful for all-caps headers) or `ch-width` (useful for monospaced fonts).
```css
h1 {
/* Normalize based on the height of capital letters */
font-size-adjust: cap-height from-font;
}
```
### 4. Verify visual stability
Ensure that the `font-size-adjust` value correctly aligns the fallback. You can test this by temporarily blocking the web font or adjusting the `font-family` declaration in your browser's DevTools and verifying that the text layout remains stable.
## Fallback strategies
Baseline status for font-size-adjust: Newly available. It's been Baseline since 2024-07-25.
Supported by: Chrome 127 (Jul 2024), Edge 127 (Jul 2024), Firefox 118 (Sep 2023), and Safari 17 (Sep 2023).
In browsers that do not support `font-size-adjust`, the font will be rendered at its default scale. This may result in layout shifts or changes in readability during font swaps.
To mitigate this without `font-size-adjust`, you can use the `@font-face` descriptors `size-adjust`, `ascent-override`, and `descent-override` to manually tune fallback fonts, though these are more complex to calculate than a single `font-size-adjust` value.
guides/visual-design/visually-stable-mixed-fonts.md
# Visually Stable Mixed Fonts
When mixing different font families, for instance when inserting inline code snippets, or switching out font families for different themes, differences in "x-height" (the height of lowercase letters) can make one font appear much smaller or larger than the other font. This can lead to poor legibility and layout shifts.
The `font-size-adjust` property allows you to normalize the visual size of text by adjusting the font size based on a specific font metric (usually the x-height).
### Implementation Steps
1. **MANDATORY**: Apply `font-size-adjust` to elements where font consistency is critical, such as containers using web fonts or blocks with mixed font families.
2. **MANDATORY**: Use the `from-font` keyword on elements to automatically match font size in nested elements to the proportions of the primary font.
3. **MANDATORY**: Use a specific numeric aspect-ratio override value for `font-size-adjust` (e.g., `font-size-adjust: 0.5`) to normalize proportions independently when the font proportions to base on are from different themes.
### Example: Normalizing x-height automatically
Using `from-font` is the most robust approach. It extracts the aspect ratio of the x-height from the first available font and applies it to fonts in child elements.
```css
.content-area {
font-family: "MyCustomWebFont";
/* Automatically extract and apply x-height ratio from MyCustomWebFont */
font-size-adjust: from-font;
}
.content-area span{
font-family: "MyOtherCustomWebFont"
}
```
### Example: Specifying a specific x-height
When the font to adjust is not a child of the font to base the size on, specify a value to adjust the x-height by.
```css
.theme{
font-family: Verdana, sans-serif;
}
.theme.alternate{
font-family: Times;
/* Set to the aspect ratio (x-height / font-size) of the primary font */
font-size-adjust: 0.51;
}
```
### Fallback strategies
Baseline status for font-size-adjust: Newly available. It's been Baseline since 2024-07-25.
Supported by: Chrome 127 (Jul 2024), Edge 127 (Jul 2024), Firefox 118 (Sep 2023), and Safari 17 (Sep 2023).
**MANDATORY**: In browsers that do not support `font-size-adjust`, fonts will render at their natural `font-size` value. You must provide a valid fallback strategy for non-supporting browsers using a `@supports` block to mitigate this:
- Use `@supports not (font-size-adjust: from-font)` to detect `font-size-adjust` support and provide fallback styles (e.g. adjusted line-height or font-size).
- Choose fonts with similar x-heights to your primary font.
- Apply specific `font-size` and `line-height` overrides for alternate and nested font families.
```css
/* Feature detection for font-size-adjust */
@supports not (font-size-adjust: from-font) {
.content-area {
/* Manual adjustment for browsers without support (if needed) */
line-height: 1.6;
font-size: 1.2rem;
}
}
```
guides/visual-design/visually-texture-content.md
# Visually Texture Content
## Overview
To apply realistic weathering or texture patterns (like grunge, noise, or paper texture) to an element, use CSS Masking (`mask-image`) with a repeating texture image. This allows you to make the content itself appear textured by making parts of it semi-transparent, rather than just overlaying a texture on top. This creates a more realistic physical material appearance.
## Implementation
To apply a texture pattern:
### Method 1: Using a repeating raster image (Recommended for realistic textures)
This is the most common method for realistic textures.
```css
.weathered-element {
/* MANDATORY: Use vendor prefix for wider support in older browsers */
-webkit-mask-image: url('grunge-pattern.png');
-webkit-mask-repeat: repeat; /* Repeat the pattern to fill the area */
-webkit-mask-size: 300px; /* Control the scale of the texture */
/* Standard property for modern browsers */
mask-image: url('grunge-pattern.png');
mask-repeat: repeat;
mask-size: 300px;
}
```
### Method 2: Using CSS Gradients for geometric patterns
You can generate patterns using CSS gradients. This is self-contained and does not require external image files.
```css
.patterned-element {
--checkerboard-gradient:
linear-gradient(45deg, #000 25%, transparent 25%),
linear-gradient(-45deg, #000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000 75%),
linear-gradient(-45deg, transparent 75%, #000 75%);
/* Apply a checkerboard pattern as a mask */
-webkit-mask-image: var(--checkerboard-gradient);
-webkit-mask-size: 20px 20px;
-webkit-mask-position: 0 0, 0 10px, 10px -10px, -10px 0px;
mask-image: var(--checkerboard-gradient);
mask-size: 20px 20px;
mask-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
```
### Alpha vs Luminance Masking Modes
By default, CSS masks use `mask-mode: match-source`. This means the browser automatically decides whether to use the **alpha channel** (transparency) or the **luminance** (brightness) of the mask based on what kind of source you provide:
| Mask Source Type | Default Mask Mode | Masking Behavior |
| :--- | :--- | :--- |
| **Inline SVG `<mask>` element** | `luminance` | Opacity is determined by the brightness of colors. **White** reveals content, **black** hides it, and **gray** creates semi-transparency. |
| **Direct Image File** (e.g. PNG, SVG file) | `alpha` | Opacity is determined by transparency. **Opaque** parts reveal content, and **transparent** parts hide it. |
| **CSS Gradient** | `alpha` | Opacity is determined by transparency. **Opaque** colors (like `black`) reveal content, and **transparent** colors hide it. |
> **Note:** You can explicitly override the default mask mode using the `mask-mode` CSS property (e.g., `mask-mode: luminance;` or `mask-mode: alpha;`).
## Fallback strategies
Baseline status for Masks: Widely available. It's been Baseline since 2023-12-07.
Supported by: Chrome 120 (Dec 2023), Edge 120 (Dec 2023), Firefox 53 (Apr 2017), and Safari 15.4 (Mar 2022).
If a browser does not support `mask-image` or the prefixed version:
- The element will display without the texture (clean and solid fill).
- Ensure the content is still readable without the texture (progressive enhancement).
- You can use a background image or an overlay as a fallback to simulate the texture, although it will not affect the transparency of the content itself.
```css
/* Fallback: Use a background image for browsers without mask support */
@supports (not (mask-image: url(x))) and (not (-webkit-mask-image: url(x))) {
.weathered-element {
/* Fallback adds texture on top or behind, depending on implementation */
background-image: url('grunge-pattern.svg');
background-color: #fff; /* Ensure background is solid if needed */
}
}
```
guides/webmcp/agentic-forms.md
# Agentic Forms
The Declarative API transforms standard HTML `<form>` elements into WebMCP tools via attributes. The browser synthesizes a JSON Schema from the form inputs and handles agent interactions.
## Form Attributes
* `toolname`: Unique name for the tool.
* `tooldescription`: Purpose of the tool.
* `toolautosubmit`: (Optional) If present, the agent can submit the form without waiting for user interaction.
* `toolparamdescription`: (Optional) Provides a way to define a property description within the JSON Schema.
* **Resolution Order**: The browser uses `toolparamdescription` if present. In its absence, it uses the `textContent` of the associated `<label>` (skipping labelable descendants). If no label exists, it falls back to the `aria-description`.
* **Grouping (Fieldsets)**: To attach a description to a group of related elements (like `<input type="radio">` buttons), place `toolparamdescription` on the nearest parent `<fieldset>` element so it applies to the parameter group as a whole.
### Example
```html
<form toolname="search-cars"
tooldescription="Perform a car make/model search"
toolautosubmit>
<label for="make">Vehicle Make</label>
<input type="text" id="make" name="make" required>
<label for="model">Vehicle Model</label>
<input type="text" id="model" name="model" toolparamdescription="e.g., 330i, F-150" required>
<button type="submit">Search</button>
</form>
```
## Handling Submissions in JavaScript
When an agent submits the form, the `SubmitEvent` includes `agentInvoked` (boolean) and `respondWith(promise)`.
```javascript
document.querySelector('form').addEventListener('submit', (event) => {
event.preventDefault();
// Validate the form
const formValidationErrors = myFormIsValid();
if (formValidationErrors.length > 0) {
if (event.agentInvoked) {
const errorString =
'Validation failed: ' +
formValidationErrors
.map((err) => `${err.field} (${err.message})`)
.join(', ');
event.respondWith(Promise.resolve(errorString));
}
return;
}
const resultPromise = performAsyncSearch(new FormData(event.target));
// Return the result directly to the agent without navigation
if (event.agentInvoked) {
event.respondWith(resultPromise);
}
});
```
## Lifecycle Events
The window emits events when agents start or stop interacting with a tool:
```javascript
window.addEventListener('toolactivated', ({ toolName }) => {
console.log(`Tool "${toolName}" was activated by the agent.`);
});
window.addEventListener('toolcancel', ({ toolName }) => {
console.log(`Tool "${toolName}" interaction was cancelled.`);
});
```
## Visual Feedback (CSS)
Use pseudo-classes to highlight forms when an agent interacts with them:
* `:tool-form-active`: Applied to the `<form>` element actively used by the agent.
* `:tool-submit-active`: Applied to the submit button when the browser pauses for user review (if `toolautosubmit` is omitted).
```css
form:tool-form-active {
outline: 2px dashed blue;
background-color: rgba(0, 0, 255, 0.05);
}
button:tool-submit-active {
outline: 2px dashed red;
animation: pulse 2s infinite;
}
```
## Form Suitability (When to Avoid)
The Declarative API is best for self-contained, standard forms. It is a poor choice in these scenarios:
* **Highly Dependent Fields**: Forms where inputs change options or visibility based on other inputs. The synthesized schema cannot express these dependencies well.
* **Custom UI Components**: Forms relying on non-standard inputs (e.g., canvas, rich text editors) that don't auto-serialize values.
* **Multi-Step Wizards**: Complex workflows requiring multiple form submissions. The Imperative API or standard DOM interaction is better suited here.
## When to use toolautosubmit
* **Read-Only Operations & Queries**: Searches, filters, fetching details, or checking status (e.g., a car model search, searching a directory, checking stock availability).
* **Low-Risk, Reversible Actions**: Form actions that can easily be undone or refined by the user manually (e.g., adding items to a cart, applying a coupon code, saving a draft, or setting temporary layout options).
## When to omit toolautosubmit
* **Destructive or Irreversible Actions**: Deleting records, resetting system configurations, or clearing databases.
* **Financial & Transactional Actions**: Submitting a checkout form, transferring funds, authorizing subscription payments, or final order placements.
* **High-Impact User Communication**: Submitting a final job application, sending emails/messages to other real users, or publishing public-facing content.
* **Sensitive Account Settings**: Changing passwords, modifying user roles/permissions, or updating billing/profile info.
## Fallback strategies
Form-associated WebMCP attributes is not natively supported by any major browser yet.
The WebMCP Declarative API is safe to use in all browsers. Browsers that do not support WebMCP will ignore the `tool*` attributes, and the `<form>` will continue to function as a normal HTML form. No feature detection is required.
guides/webmcp/agentic-javascript-tools.md
# Agentic JavaScript Tools
The Imperative API uses `document.modelContext.registerTool()` to programmatically define JavaScript tools. This is ideal for Single Page Applications (SPAs) where tools need to be added or removed based on the current route or user state.
## Registration and Lifecycle
Tools are registered by passing a tool definition object and an optional options object containing an `AbortSignal`.
### Lifecycle Handling with `AbortController`
WebMCP does not provide an `unregisterTool()` method. To unregister a tool, you must pass an `AbortSignal` during registration and abort that signal when the tool is no longer needed.
```javascript
const controller = new AbortController();
await document.modelContext.registerTool({
name: "get_user_preferences",
description: "Retrieves the user's saved preferences.",
inputSchema: { type: "object", properties: {} },
execute() {
const prefs = localStorage.getItem("user_prefs");
return prefs ? JSON.parse(prefs) : { theme: "light" };
},
annotations: { readOnlyHint: true }
}, { signal: controller.signal });
// To unregister the tool (e.g., on component unmount):
controller.abort();
```
## Defining Parameters
Parameters (params) are defined using the `inputSchema` property. This must be a **JSON Schema** object that describes the structured data the tool expects.
```javascript
await document.modelContext.registerTool({
name: "calculate_area",
description: "Calculates the area of a rectangle.",
inputSchema: {
type: "object",
properties: {
width: { type: "number", description: "The width of the rectangle." },
height: { type: "number", description: "The height of the rectangle." }
},
required: ["width", "height"]
},
execute(input) {
// input is { width: 10, height: 20 }
return input.width * input.height;
},
annotations: { readOnlyHint: true }
});
```
## Execution Patterns
### When to use `async execute`
Use `async` when the tool involves operations that return a Promise or take time to complete:
- **Network calls**: Fetching data from an API.
- **Asynchronous Storage**: Accessing IndexedDB.
- **External Events**: Waiting for a specific state change or animation to finish.
```javascript
async execute(input) {
const response = await fetch(`/api/data/${input.id}`);
return await response.json();
}
```
### When to use `execute` (Synchronous)
Use a standard synchronous function for immediate operations:
- **Pure logic**: Math, filtering, or sorting data already in memory.
- **Synchronous state**: Reading from `localStorage` or a synchronous state manager.
```javascript
execute(input) {
return input.items.filter(item => item.active);
}
```
## Tool Factory Pattern
To pass context (like stores or application instances) to your tools, use factory functions.
```javascript
export function createInventoryTool(inventoryManager) {
return {
name: "get_inventory",
description: "Lists items in the inventory.",
inputSchema: { type: "object", properties: {} },
execute() {
return inventoryManager.getItems();
},
annotations: { readOnlyHint: true }
};
}
```
## API Notes
* **annotations**: (Optional) A dictionary for tool metadata.
* **readOnlyHint**: (Optional) Set to `true` if the tool does not modify any state and only reads data. This helps agents decide when it is safe to call the tool.
* **Return Format**: The `execute` function can return any value (object, array, string, number, boolean). Select a structure that best serves your specific use case while ensuring the content is optimized for the LLM to process. The output may encompass raw data, specific error logs, or direct instructions to influence the agent's next action.
* **Secure Context**: WebMCP requires HTTPS.
* **Deprecated/Removed**: `navigator.modelContext` (deprecated in Chromium 150), `unregisterTool()`, `provideContext()`, and `clearContext()` are no longer supported.
## Fallback strategies
document.modelContext is not natively supported by any major browser yet.
The WebMCP Imperative API should be used with feature detection to ensure compatibility with browsers that do not yet support WebMCP.
```javascript
const modelContext = document.modelContext || navigator.modelContext;
if (modelContext && 'registerTool' in modelContext) {
// Register tools
}
```
guides/webmcp/webmcp.md
# WebMCP (Web Model Context Protocol)
WebMCP is a browser-native JavaScript API that allows web pages to expose their client-side functionality as structured "tools" to AI agents, browser assistants, and assistive technologies.
IMPORTANT: WebMCP is currently in Early Preview on Chromium-based browsers (such as Chrome and Edge). It requires Chromium version `146.0.7672.0` or higher and the `#enable-webmcp-testing` flag.
**Crucial Distinction:** WebMCP runs entirely **client-side** in the browser tab. It is *not* a backend server, and it does *not* use HTTP, Server-Sent Events (SSE), or `stdio` transports. The web page itself acts as the tool registry.
Currently, WebMCP **only supports Tools**. It does not support the "Resources" or "Prompts" primitives found in the backend Model Context Protocol.
## Quick Overview
- **Imperative API**: Use `document.modelContext.registerTool()` for complex logic and dynamic interactions.
- **Declarative API**: Annotate standard HTML `<form>` elements with `toolname` and `tooldescription` to turn them into tools.
## Best Practices
* **Naming and Semantics**: Use specific verbs describing exact behavior (e.g. `create-event` vs `start-event-creation-process`). Favor positive descriptions over listing limitations.
* **Schema Design**: Accept raw user input (avoid agent math/calculation). Ensure all parameters have specific types and explain the purpose of options.
* **Reliability**: Validate constraints in code and return descriptive errors for retries. Handle rate limiting gracefully. Ensure the function returns *after* UI state updates for consistency.
* **Tool Strategy**: Tools should be atomic, composable, and distinct. Do not force flow control instructions ("Don't call B after A") — let the agent decide. Register/unregister tools dynamically depending on the current page context. Use `annotations: { readOnlyHint: true }` (placed after `execute`) for tools that do not modify state to inform the agent of safe execution.
* **Clean Up**: Always use `AbortSignal` to unregister tools when pages transition or resources are released to avoid leaks and collisions. Do not use `unregisterTool`.
* **Web Development Best Practices**: WebMCP tools run as client-side JavaScript in the browser tab. They must adhere to regular web development best practices (e.g., keeping secrets out of client-side code, accessing backend databases through secure API layers, and using Web Workers, WASM, or WebGPU for heavy compute).
### When to Discourage WebMCP
* **High-Risk Actions without Guardrails**: Avoid auto-submitting tools for destructive or irreversible actions (e.g., deleting data) unless the UI requires manual user confirmation outside the agent's control.
* **Hyper-Dynamic State**: If data changes faster than the agent can react, it may work with stale context.
### Anti-Patterns & Warnings (DO NOT DO THIS)
* **Do not use backend transports.** WebMCP is for browser tabs, not Node.js background processes.
* **Do not include Resources or Prompts.** These are not supported in the current WebMCP spec.
* **Do not ignore `inputSchema` structure.** Always provide clear descriptions for every parameter to minimize agent hallucinations.
* **Do not use outside of a Secure Context (HTTPS).**
## Implementation Status
WebMCP is currently in early preview in Chromium-based browsers (e.g., Chrome, Edge):
* **Current Status**: Early preview.
* **Required Version**: Chromium `146.0.7672.0` or higher.
* **Activation**: Requires enabling the flag `chrome://flags/#enable-webmcp-testing` or `edge://flags/#enable-webmcp-testing`.
* **Specification**: Evolving [Draft Community Group specification](https://webmachinelearning.github.io/webmcp/); not yet a standards-track recommendation.
SKILL.md
---
name: modern-web-guidance
description: |
Search tool for modern web development best practices. MANDATORY: Execute FIRST for all HTML/CSS and clientside JS tasks. Do NOT skip — web APIs evolve rapidly and training weights contain obsolete patterns.
Trigger immediately for:
- UI/Layout: Modals, dialogs, popovers, Glassmorphism/backdrop-filters, anchor positioning, container queries, `:has()`, `:user-valid`.
- Scroll/Motion: View Transitions, Scroll-driven animations, scroll parallax/reveals.
- Performance: CWV (LCP, INP), content-visibility, Fetch Priority, image optimization.
- System/APIs: Local filesystem access, WebUSB, WebSockets sync, WebAssembly widgets.
- Frameworks: Adapting layout/styles in React, Vue, Angular.
- General Frontend: Forms, autofill, advanced inputs, custom scrollbars, modern component states, etc.
DO NOT trigger for:
- Backend: Database SQL, ORMs, Express API routes.
- Pipelines: CI/CD deployment, Docker, Actions.
- Generic: Local scripts (Python/Go tools), ESLint, Git.
---
# Modern Web Guidance
A skill to search for specific web development use cases and retrieve their corresponding best practice guides.
## When to use
Must use this skill:
- At the **start** of implementing any web feature.
- Before creating a new component, to check if a standardized pattern already exists.
- To avoid implementing ad-hoc solutions or loading large dependencies unnecessarily.
## Usage Instructions
### Step 1. Search Use Cases
Search with an action-oriented query summarizing what you want to achieve using the `search` command. Run `modern-web-guidance` directly with `npx`.
```sh
npx -y modern-web-guidance@latest search "<query>" --skill-version 2026_08_31-6ba3cecd
```
**Example Output**:
```json
[
{
"id": "optimize-image-priority",
"description": "Optimize the loading priority of Largest Contentful Paint (LCP) candidate images.",
"category": "performance",
"featuresUsed": [ "Fetch priority" ],
"tokenCount": 985,
"similarity": 0.7289
},
{
"id": "defer-rendering-heavy-content",
"description": "Reduce rendering times in content-heavy web pages by deferring rendering for offscreen content.",
"category": "performance",
"featuresUsed": [ "content-visibility", "hidden=\"until-found\"" ],
"tokenCount": 1250,
"similarity": 0.6961
}
]
```
> **Note**: If search results are vague, return no matches, or show low similarity scores, run the `list` command to browse all guides:
> ```sh
> npx -y modern-web-guidance@latest list
> ```
---
### Step 2. Retrieve Best Practices
Once you have a relevant `id` from the search results, call this script using the `retrieve` command to get the full guide. You can pass multiple IDs separated by commas.
```sh
npx -y modern-web-guidance@latest retrieve "<id>"
```
If the output is truncated, you must repeat the command but redirect to a file and read that file.
**Example Output**:
`The markdown content of the guide describing implementation steps...`
---
### Step 3. Verify Guidance Compliance
When generating or modifying code, cross-check the implementation against the retrieved guide before concluding:
- **Applicable Guidance & Fallbacks**: Ensure the relevant modern patterns and necessary fallback strategies from the guide are correctly applied, without forcing unrequested features.
- **Task Fulfillment**: Confirm that the implementation fully satisfies the user's request.
## Using npx / pnpx
- Prefer `pnpx` over `npx` if `pnpm` is available (note: `pnpx` does not use the `-y` flag).
- When requesting tool permissions, allowlist `npx -y modern-web-guidance@latest *` specifically (or `pnpx modern-web-guidance@latest *`), never bare `npx *` or `pnpx *`.
- IMPORTANT: on Windows, using `npx` may fail. Use `npx.cmd ...` instead.
- Network access is required for fetching npm packages needed by the task.
- If the `npx -y modern-web-guidance…` command hangs, you may be offline. Try running again in offline
mode: `npx --offline …`.
- The `--skill-version` flag is used to determine if this SKILL.md is out of date. If it is, a warning
message is logged to stderr.
## Guidelines
- Always search **first** to find the most relevant guides.
- These guides are usually framework-agnostic; adapt them correctly to your setup.
- Do not hallucinate guides or ignore them; they represent the preferred local standard for the user's project.
## Interpreting Browser Support & Fallbacks
* **Default Behavior**: All guides assume **Baseline Widely available** features are safe to use without fallbacks. For features that are not Baseline widely available, you **MUST** follow the fallback recommendations in the guide, unless the user has specified a custom browser support policy.
* **Custom Policies**: If the user has already defined explicit browser support requirements, use the browser compatibility data in the guide to determine if a fallback can be safely ignored.
- For Baseline YYYY targets, a feature satisfies this target if its "Baseline since" date is <= YYYY.
- **Policy Examples**:
- _"Do not implement feature fallbacks."_ (for exploratory prototypes of the cutting-edge web)
- _"Safari 17.4+"_ (for internal tools targeting macOS or Tauri-based desktop apps)
- _"Never recommend or implement polyfills; if a Baseline Newly Available feature is required for core functionality, provide a lightweight custom fallback or redesign the approach."_ (to minimize bundle size and avoid technical debt)
- _"Assume a modern execution environment where Baseline Newly Available features can be used natively, provided they are strictly feature-detected and degrade gracefully."_ (for progressive enhancement strategies)
* **Reactive Policy Discovery**: Watch for environmental cues to suggest documenting a policy in CLAUDE.md or AGENTS.md. Suggest this if the developer:
- Mentions building for a restricted runtime (e.g., Electron or Tauri).
- Explicitly excludes specific targets (e.g., "we don't support Desktop Chrome").
- Expresses hesitation about polyfill complexity, bundle size, or performance cost.
- Questions if a feature is safe to use without fallbacks.
No defined policy format. This is an example: `**Browser Support:** Allow Newly Available features, but only adopt custom fallback code that adds <= 20 lines and does not require external dependencies.`