<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Coding on Smashing Magazine — For Web Designers And Developers</title><link>https://www.smashingmagazine.com/category/coding/index.xml</link><description>Recent content in Coding on Smashing Magazine — For Web Designers And Developers</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Tue, 12 May 2026 16:11:33 +0000</lastBuildDate><item><author>Durgesh Pawar</author><title>The Architecture Of Local-First Web Development</title><link>https://www.smashingmagazine.com/2026/05/architecture-local-first-web-development/</link><pubDate>Wed, 06 May 2026 10:00:00 +0000</pubDate><guid>https://www.smashingmagazine.com/2026/05/architecture-local-first-web-development/</guid><description>What does it really take to build local-first web apps in 2026? A grounded, experience-driven perspective for developers who&amp;rsquo;ve been doing this long enough to be skeptical of silver bullets.</description><content:encoded><![CDATA[
          <html>
            <head>
              <meta charset="utf-8">
              <link rel="canonical" href="https://www.smashingmagazine.com/2026/05/architecture-local-first-web-development/" />
              <title>The Architecture Of Local-First Web Development</title>
            </head>
            <body>
              <article>
                <header>
                  <h1>The Architecture Of Local-First Web Development</h1>
                  
                    
                    <address>Durgesh Pawar</address>
                  
                  <time datetime="2026-05-06T10:00:00&#43;00:00" class="op-published">2026-05-06T10:00:00+00:00</time>
                  <time datetime="2026-05-06T10:00:00&#43;00:00" class="op-modified">2026-05-12T16:11:33+00:00</time>
                </header>
                
                

<p>Last October, I was sitting in a hotel room in Lisbon, the night before I was supposed to demo a project management tool my team had spent four months building. The hotel Wi-Fi was doing that thing where it <em>connects</em> but nothing actually loads. And I watched our app, this thing I was genuinely proud of, render a blank screen with a spinner. Then a timeout error. Then nothing.</p>

<p>I pulled out my phone, tethered to cellular, and got a shaky connection. The app loaded, but every click was a two-second wait. Create a task? Spinner. Move a task between columns? Spinner. I sat there thinking: we built a front end in React, a back end in Node, a Postgres database, a Redis cache, a GraphQL API with six resolvers just for the task board. All that infrastructure, and the damn thing can’t show me my own data without a round-trip to a server 3,000 miles away.</p>

<p>That was the night I started seriously looking at <strong>local-first architecture</strong>. Not because I read a blog post or saw a tweet. Because I was <em>embarrassed</em>.</p>

<p>I want to be upfront about something: I spent the first year or so dismissing local-first as academic. I read the <a href="https://www.inkandswitch.com/local-first-software/">Ink &amp; Switch “Local-First Software” paper</a> when it came out in 2019 and thought, <em>“Cool research, not practical for real apps.”</em> I was wrong. The tooling in 2019 genuinely wasn’t ready. But I was also being lazy, defaulting to the architecture I already knew. The paper laid out seven ideals for software: <strong>fast, multi-device, offline, collaboration, longevity, privacy, user ownership</strong>. And I remember thinking those sounded like a wish list, not engineering requirements.</p>

<p>Seven years later, I’ve shipped three production apps using local-first patterns. I’ve also ripped local-first out of two projects where it was the wrong call. I have opinions. Some of them are probably wrong. But they’re earned.</p>

<p>So here’s what I actually think about building local-first web apps in 2026, written for developers who’ve been doing this long enough to be skeptical of silver bullets.</p>

<div data-audience="non-subscriber" data-remove="true" class="feature-panel-container">

<aside class="feature-panel" style="">
<div class="feature-panel-left-col">

<div class="feature-panel-description"><p>Meet <strong><a data-instant href="https://www.smashingconf.com/online-workshops/">Smashing Workshops</a></strong> on <strong>front-end, design &amp; UX</strong>, with practical takeaways, live sessions, <strong>video recordings</strong> and a friendly Q&amp;A. With Brad Frost, Stéph Walter and <a href="https://smashingconf.com/online-workshops/workshops">so many others</a>.</p>
<a data-instant href="smashing-workshops" class="btn btn--green btn--large" style="">Jump to the workshops&nbsp;↬</a></div>
</div>
<div class="feature-panel-right-col"><a data-instant href="smashing-workshops" class="feature-panel-image-link">
<div class="feature-panel-image">
<img
    loading="lazy"
    decoding="async"
    class="feature-panel-image-img"
    src="/images/smashing-cat/cat-scubadiving-panel.svg"
    alt="Feature Panel"
    width="257"
    height="355"
/>

</div>
</a>
</div>
</aside>
</div>

<h2 id="what-local-first-actually-means-and-the-confusion-that-won-t-die">What “Local-First” Actually Means (And The Confusion That Won’t Die)</h2>

<p>I need to clear something up because I keep having this conversation at meetups. <strong>Local-first is not offline-first.</strong> It’s not “add a service worker and call it a day.” It’s not a synonym for PWA. I’ve seen all of these conflated in conference talks, and it drives me a little crazy.</p>

<p>Offline-first means your app handles network loss gracefully, but <a href="https://www.smashingmagazine.com/2019/04/cloudflare-workers-serverless/">the server is still the source of truth</a>. When the network comes back, the server wins. Cache-first (service workers caching responses) is a performance optimization. You’re serving stale data faster, which is great, but you haven’t changed who <em>owns</em> the data. PWAs are a delivery mechanism: installable, cached, push notifications. None of these is a data architecture.</p>

<p><strong>Local-first is a data architecture.</strong> Your user’s device holds the primary copy of their data. The app reads and writes to a local database. Renders instantly. Syncs with servers or other devices in the background. The server, when it exists, is a sync peer with some special authority (authentication, backup, access control). But it’s not the gatekeeper.</p>

<p>The Ink &amp; Switch paper defined seven ideals, and I think they still hold up. But the one that matters most in practice, the one that changes how you build everything, is this:</p>

<blockquote>The client is not a thin view requesting permission to show data. The client is a <strong>node</strong> in a distributed system with its own database.</blockquote>

<p>That distinction sounds subtle. It isn’t. It changes your entire stack.</p>

<h2 id="be-honest-early-when-you-should-not-do-this">Be Honest Early: When You Should Not Do This</h2>

<p>I’m putting this near the top because I’ve watched too many developers (including myself, once) get excited about a new architecture and shoehorn it into projects where it doesn’t belong. I wasted about six weeks trying to make a local-first approach work for an internal analytics dashboard at a previous job. My colleague Sarah finally pulled me aside and said, <em>“The data is generated on the server. There’s nothing to replicate to the client. What are you doing?”</em> She was right.</p>

<p>Local-first is a bad fit when your data is primarily server-generated. Analytics dashboards, social media feeds, search results: the server <em>produces</em> this data, so the client consuming it via API requests is completely fine.</p>

<p>It’s wrong for systems that need strong transactional consistency. Banking, payment processing, and inventory management. If two people try to buy the last item in stock, you need a single authoritative database making that decision with <a href="https://en.wikipedia.org/wiki/ACID">ACID</a> guarantees. Eventual consistency will lose you money, or worse.</p>

<p>It’s overkill for simple CRUD apps with no offline or collaboration needs. If you’re building an internal admin panel used by five people in an office with good internet, adding a sync engine is over-engineering. And it’s physically impractical for massive datasets that won’t fit on client devices.</p>

<p>But here’s where it shines: note-taking, document editing, collaborative design tools, project management, field apps with unreliable connectivity, basically anything where <strong>data privacy is a selling point</strong>, as well as anything with <strong>real-time collaboration</strong>. In other words, it’s great for <strong>user-generated data</strong> that benefits from instant interaction and should survive the server going down.</p>

<p>One more thing I wish someone had told me earlier: you don’t have to go all-in. I’ve had the best results using local-first for <em>specific features</em> within otherwise traditional apps. Offline drafts in a blog editor. Real-time collaborative notes inside a project management tool that’s otherwise standard REST.</p>

<blockquote class="pull-quote">
  <p>
    <a class="pull-quote__link" aria-label="Share on Twitter" href="https://twitter.com/share?text=%0aThe%20%e2%80%9cspectrum%20of%20local-first%e2%80%9d%20is%20a%20real%20thing,%20and%20starting%20with%20one%20feature%20is%20how%20I%e2%80%99d%20recommend%20anyone%20begin.%0a&url=https://smashingmagazine.com%2f2026%2f05%2farchitecture-local-first-web-development%2f">
      
The “spectrum of local-first” is a real thing, and starting with one feature is how I’d recommend anyone begin.

    </a>
  </p>
  <div class="pull-quote__quotation">
    <div class="pull-quote__bg">
      <span class="pull-quote__symbol">“</span></div>
  </div>
</blockquote>

<h2 id="replicas-not-requests">Replicas, Not Requests</h2>

<p>If you’ve used Git, you already understand the mental model.</p>

<p>SVN (remember SVN?) was centralized. One server. You check out files, make changes, and commit to the server. Server down? Can’t commit. Can’t even see history.</p>

<p>Git gave every developer a full clone. You commit locally, branch locally, and merge locally. Push and pull when you’re ready. The remote repository is important, but it’s not the only copy of the truth.</p>

<p><strong>Local-first web development is Git for application data.</strong> Every client device holds a replica (full or partial) of the relevant data. Writes happen locally. Sync is push/pull in the background. Conflicts get resolved through defined merge strategies.</p>

<p>I remember the first time this clicked for me in practice. I was prototyping a task board, and I wrote a function to add a task. In our old architecture, it would be:</p>

<ol>
<li>POST to API.</li>
<li>Wait for the response.</li>
<li>If success, update the local state.</li>
<li>If failure, show error toast and maybe roll back optimistic update.</li>
</ol>

<p>In the local-first version, it was: write to local SQLite, done. The UI updated instantly because it was reading from the same local database. Sync happened whenever. No loading state, no error handling for the write itself, no optimistic update logic (because there’s nothing to be “optimistic” about; the local write <em>is</em> the state).</p>

<p>The implications ripple through everything. You don’t need React Query or SWR for data fetching, because you’re not fetching. You don’t need Redux or Zustand for server-derived state, because the local database <em>is</em> your state. Your routing doesn’t trigger API calls. Authentication works differently because the server isn’t checking permissions on every read.</p>

<p>Here’s a visual comparison that might help if you’re the kind of person (like me) who thinks spatially:</p>














<figure class="
  
  
  ">
  
    <a href="https://files.smashing.media/articles/architecture-local-first-web-development/local-first-vs-traditional-architecture.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="800"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/architecture-local-first-web-development/local-first-vs-traditional-architecture.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/architecture-local-first-web-development/local-first-vs-traditional-architecture.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/architecture-local-first-web-development/local-first-vs-traditional-architecture.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/architecture-local-first-web-development/local-first-vs-traditional-architecture.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/architecture-local-first-web-development/local-first-vs-traditional-architecture.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/architecture-local-first-web-development/local-first-vs-traditional-architecture.png"
			
			sizes="100vw"
			alt="Traditional request/response architecture vs. local-first architecture"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Traditional request/response architecture vs. local-first architecture. (<a href='https://files.smashing.media/articles/architecture-local-first-web-development/local-first-vs-traditional-architecture.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>On the left, every user interaction is a round-trip. Click, wait, render. On the right, reads and writes hit the local database directly. The sync server is still there, but it’s doing its work in the background. The user never waits for it. That’s the fundamental shift.</p>

<p>But I’m getting ahead of myself. Before we can talk about sync and conflicts, we need to talk about where the data actually lives on the client.</p>

<h2 id="where-data-lives-on-the-client">Where Data Lives on the Client</h2>

<p>Forget <code>localStorage</code>. It’s synchronous (blocks the main thread), caps at 5-10 MB, and only stores strings. It’s fine for a theme preference. It’s not a database.</p>

<p>IndexedDB is the workhorse that nobody loves. It’s in every browser, it’s asynchronous, it can handle hundreds of megabytes, and its API is absolutely miserable to work with. I’ve used it directly a grand total of once. Now I use it through abstractions or, more often, I don’t use it at all.</p>

<p>Because the real story in 2026 is SQLite running in the browser via WebAssembly.</p>

<p>I know that sounds like a party trick, but it’s not. SQLite compiled to WASM, persisted to the Origin Private File System (OPFS), gives you a <em>real relational database</em> in the browser. Full SQL queries. Transactions. Indexes. The works.</p>

<p>OPFS is the newer API that makes this practical. It gives web apps a sandboxed file system with high-performance synchronous access (in Web Workers), which is exactly what SQLite needs. Before OPFS, you could run SQLite in memory and manually persist to IndexedDB, which worked but was slow and fragile.</p>

<p>Here’s roughly what initialization looks like in a real project (I’m using <a href="https://github.com/rhashimoto/wa-sqlite"><code>wa-sqlite</code></a> here, which is the library I’ve had the best luck with):</p>

<div class="break-out">
<pre><code class="language-typescript">import { SQLiteAPI } from 'wa-sqlite';
import { OPFSCoopSyncVFS } from 'wa-sqlite/src/examples/OPFSCoopSyncVFS.js';

async function initDatabase() {
  const module = await SQLiteAPI.initialize();
  const vfs = new OPFSCoopSyncVFS('pm-tool-db');
  await vfs.initialize(module);

  const db = await module.open&#95;v2('workspace.db');

  // HACK: wa-sqlite doesn't handle concurrent writes well on Safari,
  // so we serialize through a queue. See vlcn-io/wa-sqlite&#35;247
  await module.exec(db, `PRAGMA journal&#95;mode=WAL`);

  await module.exec(db, `
    CREATE TABLE IF NOT EXISTS tasks (
      id TEXT PRIMARY KEY,
      title TEXT NOT NULL,
      status TEXT DEFAULT 'backlog',
      assignee&#95;id TEXT,
      project&#95;id TEXT NOT NULL,
      position REAL DEFAULT 0,
      created&#95;at TEXT DEFAULT (datetime('now')),
      updated&#95;at TEXT DEFAULT (datetime('now'))
    )
  `);

  return db;
}
</code></pre>
</div>

<p>In production, I wrap all database access in a write queue that serializes mutations. I also log every failed write to Sentry with the full SQL statement (scrubbed of PII, obviously) because debugging database issues in a user’s browser is hell without that telemetry.</p>

<p>A gotcha I wasted almost two days on: Safari’s OPFS implementation behaves differently from Chrome’s in subtle ways. Specifically, I hit a bug where <code>createSyncAccessHandle()</code> would silently fail in certain iframe contexts on Safari 18. There’s no error, no exception. It just doesn’t work. I ended up falling back to IndexedDB-backed persistence on Safari, which was slower but at least functioned. (I’m told <a href="https://developer.apple.com/documentation/safari-release-notes/safari-26-release-notes">Safari <sup>19</sup>&frasl;<sub>26</sub></a> fixes this, but I haven’t verified it yet.)</p>

<p>Quick comparison of the options I’ve actually used:</p>

<table class="tablesaw break-out">
    <thead>
        <tr>
            <th>Storage</th>
            <th>Good For</th>
      <th>Watch Out For</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>IndexedDB</td>
            <td>Broad compatibility, moderate data</td>
      <td>Terrible DX, no SQL, verbose</td>
        </tr>
        <tr>
            <td>OPFS + SQLite WASM</td>
            <td>Relational data, complex queries, serious apps</td>
      <td>Safari quirks, ~400KB bundle addition</td>
        </tr>
        <tr>
            <td>PGlite (Postgres in WASM)</td>
            <td>Full Postgres compatibility on client</td>
      <td>Newer, larger bundle, still maturing</td>
        </tr>
    </tbody>
</table>

<p>I’ve also tried <a href="https://github.com/vlcn-io/cr-sqlite"><code>cr-sqlite</code></a>, which adds CRDT column support directly to SQLite tables. Clever idea, but I found it too early-stage for production use when I evaluated it in late 2025. The merge semantics were sometimes surprising, and debugging CRDT state inside SQLite was painful. I’d revisit it later this year.</p>

<div class="partners__lead-place"></div>

<h2 id="the-part-that-s-actually-hard">The Part That’s Actually Hard</h2>

<p>Storing data locally is a solved problem. Syncing it reliably across devices and users is where you earn your gray hairs.</p>

<p>When multiple replicas can independently read and write, you need a mechanism to reconcile changes. There are basically four approaches, and I’ve used three of them.</p>

<p><strong>CRDTs (Conflict-Free Replicated Data Types)</strong> are data structures designed so that concurrent edits can always be merged without conflicts, mathematically guaranteed. Yjs is the most popular implementation in JavaScript, and it’s genuinely excellent for real-time collaborative text editing. I used it to build a collaborative document editor at my last company, and the experience was mostly good, though I’ll get into the pain points in the conflict resolution section.</p>

<p>Here’s what setting up a shared Yjs document looks like in practice:</p>

<pre><code class="language-typescript">import &#42; as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

const ydoc = new Y.Doc();

const provider = new WebsocketProvider(
  'wss://sync.our-app.dev',
  'workspace-a1b2c3d4',
  ydoc
);

const tasks = ydoc.getMap('tasks');

// Add a task
const task = new Y.Map();
task.set('title', 'Review Q3 roadmap draft');
task.set('completed', false);
task.set('assignee', 'maria');
// TODO: type this properly once; yjs exports better TS types
// for nested maps. For now, this works fine.
tasks.set('f47ac10b-58cc-4372-a567-0e02b2c3d479', task as any);

tasks.observeDeep(() =&gt; {
  // Re-render UI. In practice, I debounce this to ~16ms
  // because observeDeep fires a LOT during active collaboration
  renderTaskList(tasks.toJSON());
});
</code></pre>

<p><a href="https://github.com/automerge/automerge">Automerge</a> is the other major CRDT library, backed by Rust and with a document-oriented model. I’ve used it less, but I know teams who swear by it. <a href="https://github.com/loro-dev/loro">Loro</a> is newer, Rust-based, and claims better performance. I haven’t shipped anything with Loro yet.</p>

<p><strong>Database replication</strong> is the other big approach, and honestly, for most apps that don’t need Google Docs-style real-time text editing, I think it’s the better choice. The idea is straightforward: replicate rows between a server database (Postgres) and a client database (SQLite) with a sync engine managing the plumbing.</p>

<p><a href="https://www.powersync.com">PowerSync</a> does this well. It gives you one-way replication from Postgres to client SQLite with a write-back path for mutations. ElectricSQL is more ambitious, going for full active-active sync between Postgres and SQLite. I’ve used PowerSync in production and <a href="https://electric-sql.com/docs/intro">ElectricSQL</a> in prototypes. PowerSync felt more stable when I evaluated them both in early 2026, but ElectricSQL’s approach is more powerful if they nail the execution.</p>

<p><a href="https://github.com/aspen-cloud/triplit">Triplit</a> takes a different angle entirely: it’s a full-stack database with sync built in, so you don’t think about “client DB” and “server DB” separately. I haven’t tried it beyond a weekend prototype, but the developer experience was surprisingly nice.</p>

<p><strong>Event sourcing</strong> (syncing a log of mutations rather than the current state) is the approach <a href="https://livestore.dev">LiveStore</a> takes. I find it intellectually appealing and occasionally useful, but in practice, I’ve found that reconstructing state from an event log adds complexity that most apps don’t need. My controversial opinion: Event sourcing is over-recommended for application development. It’s great for audit logs and certain domains, but for a task board? Just sync the rows.</p>

<p>Not everyone will agree with that. I know event sourcing has passionate advocates, and I’ve been told I’m wrong about this at least twice at conferences. Maybe I just haven’t built the right app for it yet.</p>

<h2 id="conflicts-the-thing-everyone-s-afraid-of">Conflicts: The Thing Everyone’s Afraid Of</h2>

<p>I used to think conflict resolution was a terrifying, unsolvable problem. After building three apps that handle it, I’d revise that to: it’s a <em>manageable</em> problem that requires you to think carefully about your specific data model, and most developers overthink it.</p>

<p>Conflicts happen when two replicas modify the same data without seeing each other’s changes. User A edits a task title on their phone while offline. User B edits the same title on their laptop. Both come back online. Now what?</p>

<p>My first attempt at handling this was embarrassingly naive:</p>

<pre><code class="language-typescript">// My first try. Don't do this.
function resolveConflict(local: any, remote: any) {
  // just... take the remote one? sure?
  return remote;
}
</code></pre>

<p>The problem is obvious: local changes get silently dropped. User A edits a title, syncs, and their edit vanishes. They don’t even know it happened.</p>

<p>What actually works for most cases is <strong>last-write-wins (LWW)</strong> at the <em>field</em> level, not the record level. If User A changes the title and User B changes the due date, you keep both changes because they touched different fields. You only have a real conflict when both modified the same field, and then you pick the later timestamp.</p>

<div class="break-out">
<pre><code class="language-typescript">interface FieldValue {
  value: string | number | boolean;
  // ISO timestamp with enough precision to break most ties
  updatedAt: string;
  // Client ID as tiebreaker when timestamps match.
  // This happens more often than you'd think.
  clientId: string;
}

function pickWinner(a: FieldValue, b: FieldValue): FieldValue {
  const timeA = new Date(a.updatedAt).getTime();
  const timeB = new Date(b.updatedAt).getTime();
  if (timeA !== timeB) return timeA &gt; timeB ? a : b;
  // Deterministic tiebreaker when timestamps match
  return a.clientId &gt; b.clientId ? a : b;
}

// In practice, I apply this per-field across the whole record.
function mergeTask(local: Record&lt;string, FieldValue&gt;, remote: Record&lt;string, FieldValue&gt;) {
  const merged: Record&lt;string, FieldValue&gt; = {};
  const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)]);
  for (const key of allKeys) {
    if (!local[key]) { merged[key] = remote[key]; continue; }
    if (!remote[key]) { merged[key] = local[key]; continue; }
    merged[key] = pickWinner(local[key], remote[key]);
  }
  return merged;
}
</code></pre>
</div>

<p>In our production app, this handles about 95% of conflicts without any user-visible issues. For the remaining cases (two people editing the same text field), LWW means one person’s edit silently wins. For a task title? Honestly, that’s usually fine. For a document body? No. That’s where CRDTs earn their keep.</p>

<p>But there’s a subtler problem I didn’t appreciate until I hit it: <strong>semantic conflicts</strong>. Data merges cleanly at the structural level, but the result is nonsensical. Two users, both offline, book the same 2 PM meeting slot with different meetings. Field-level merge accepts both writes because they’re writing to different records. No structural conflict. But you’ve got a double-booking, and your merge function has no idea that’s a problem.</p>

<p>Semantic conflicts require application-level validation, and that has to happen on the server during sync. Your sync engine merges the data structurally, but <em>your</em> server needs to check domain invariants before accepting the result. The approach I’ve landed on (after getting it wrong twice) is: validate on the server during the write-back phase, but <em>flag</em> violations rather than silently rejecting them.</p>

<p>Here’s what I mean. When the client pushes mutations to the server during sync, the server runs them through a constraint validation layer before applying them to Postgres:</p>

<div class="break-out">
<pre><code class="language-typescript">interface SyncViolation {
  type: 'scheduling&#95;conflict' | 'capacity&#95;exceeded' | 'stale&#95;assignment';
  recordId: string;
  description: string;
  // The conflicting records so the client can show context
  conflictingRecords: string[];
  // When was this violation detected
  detectedAt: string;
}

async function validateSyncBatch(
  mutations: SyncMutation[],
  serverDb: Database
): Promise&lt;{ accepted: SyncMutation[]; violations: SyncViolation[] }&gt; {
  const accepted: SyncMutation[] = [];
  const violations: SyncViolation[] = [];

  for (const mutation of mutations) {
    if (mutation.table === 'calendar&#95;events') {
      // Check for double-booking
      const overlapping = await serverDb.query(
        `SELECT id, title FROM calendar&#95;events
         WHERE room&#95;id = ? AND id != ?
         AND start&#95;time &lt; ? AND end&#95;time &gt; ?`,
        [mutation.data.room&#95;id, mutation.data.id,
         mutation.data.end&#95;time, mutation.data.start&#95;time]
      );

      if (overlapping.length &gt; 0) {
        violations.push({
          type: 'scheduling&#95;conflict',
          recordId: mutation.data.id,
          description: `Conflicts with "${overlapping[0].title}"`,
          conflictingRecords: overlapping.map(r =&gt; r.id),
          detectedAt: new Date().toISOString()
        });
        // Still accept the write, but flag it
        // The alternative is rejecting it, but then the user's
        // local state and server state diverge, and that's worse
        accepted.push(mutation);
        continue;
      }
    }
    accepted.push(mutation);
  }

  return { accepted, violations };
}
</code></pre>
</div>

<p>The key decision here &mdash; and I went back and forth on this &mdash; is that we <em>accept</em> the conflicting write and flag it, rather than rejecting it outright. If you reject it, the user’s local database has a record that the server refuses to acknowledge, and now you’re in a state divergence situation that’s genuinely hard to recover from. I tried the rejection approach first, and it led to ghost records on the client that users couldn’t delete because they didn’t exist on the server. Nightmare.</p>

<p>So instead, the server accepts the write, stores the violation, and syncs the violation back to the client. The client shows a non-blocking notification: <em>“Your meeting ‘Q3 Planning’ conflicts with ‘Design Review’ in Room B at 2 PM. Tap to resolve.”</em> The user taps, sees both meetings, and picks one to reschedule or cancel. The resolution is a normal write that syncs back.</p>

<p>Is this perfect? No. There’s a window between when the violation is created and when the user resolves it, where both conflicting records exist. For meeting rooms, that’s tolerable. For something like inventory management where two people “buy” the last item, that window is unacceptable, and that’s exactly why I said earlier that local-first is wrong for systems requiring strong transactional consistency.</p>

<p>I’m still iterating on this pattern. The violation table grows if users ignore notifications (we expire them after 72 hours, which feels arbitrary). And deciding <em>which</em> invariants to validate on the server requires you to essentially maintain a parallel set of business rules outside your client-side application logic. It’s not elegant. But it works, and it’s the best approach I’ve found for the class of apps I’m building. If you’ve built something cleaner, I genuinely want to hear about it.</p>

<p>For CRDTs like Yjs, conflict resolution at the character level (for text) works remarkably well. Two people typing in the same paragraph will see both sets of characters appear in a sensible order. But CRDT merging of structured data (maps, arrays, nested objects) can produce results that surprise you. I once watched a Yjs-backed task list duplicate items after a merge because two users had reordered the same list offline, and the CRDT’s list merge semantics interleaved their orderings. Technically correct. Practically confusing. We ended up adding a post-merge de-duplication step, which felt like a hack but solved the problem.</p>

<p>When should you surface conflicts to the user, Git-style? In my experience, almost never for typical app data. Users don’t want to resolve merge conflicts. They want the app to figure it out. The exception is high-stakes content: legal documents, medical records, anything where silently dropping an edit could cause real harm.</p>

<h2 id="the-tools-right-now">The Tools Right Now</h2>

<p>I’m going to give you my honest read on the tools available as of mid-2026, with the caveat that this space is moving fast enough that some of this might be outdated by the time you read it.</p>

<p><strong>Yjs</strong> is the most mature CRDT library. Production-ready, huge community, integrates with most collaborative editors (TipTap, BlockNote, Lexical). If you need real-time collaborative editing, start here.</p>

<p><strong>Automerge</strong> is solid, Rust-backed, and takes a more document-oriented approach than Yjs. I’ve seen it used well in apps where the data model fits a document metaphor. Fewer integrations than Yjs, but the core is well-engineered.</p>

<p><strong>PowerSync</strong> is what I’d recommend for teams that have an existing Postgres back-end and want to add offline support. It’s production-ready, the docs are good, and the mental model (Postgres syncs to client SQLite, client writes go through a defined upload path) is easy to reason about. In our app, initial sync for a workspace with around 5,000 tasks takes about 1.2 seconds on a decent connection and about 3.5 seconds on a throttled 3G simulation. That was acceptable for us.</p>

<p><strong>ElectricSQL</strong> is going for something more ambitious: true active-active replication between Postgres and SQLite, with “shapes” defining what data syncs to which client. I want this to succeed because the developer experience in prototypes was excellent. But when I evaluated it for production in February 2026, I hit enough rough edges (particularly around shape management and reconnection behavior) that I went with PowerSync instead. I plan to revisit it.</p>

<p><strong>Triplit</strong> impressed me in a weekend prototype. Full-stack database with sync built in, nice TypeScript API. I haven’t stress-tested it with real production load, and I’d want to before committing.</p>

<p><a href="https://zero.rocicorp.dev"><strong>Zero</strong></a> (from Rocicorp, the Replicache people) is interesting because it takes a query-based approach to sync, which is different from the row-replication model. Replicache was sunset in favor of Zero, which tells you something about how fast approaches are evolving in this space. Worth watching, but I wouldn’t build on it yet for a production app.</p>

<p><a href="https://tinybase.org/"><strong>TinyBase</strong></a> is a lightweight reactive store that’s great for smaller apps or prototyping. I used it for a personal side project (a reading tracker) and liked it a lot. Not sure I’d use it for a team-scale product.</p>

<p><a href="https://pglite.dev"><strong>PGlite</strong></a> (Postgres compiled to WASM) is wild. Same SQL dialect on client and server. Combined with ElectricSQL, you could theoretically run identical queries everywhere. I think this is where things are heading long-term, but PGlite’s bundle size and memory footprint are still concerns for mobile browsers.</p>

<p>One thing the Replicache sunset taught me: don’t bet your architecture on a single tool from a small company without a fallback plan. I keep my sync layer abstracted enough that I could swap engines in a few weeks, not months. I know that sounds like premature abstraction, but in a space this young, I think it’s just prudence.</p>

<div class="partners__lead-place"></div>

<h2 id="building-a-real-app-architecture-auth-and-migrations">Building A Real App: Architecture, Auth, And Migrations</h2>

<p>I want to walk through how I actually structure a local-first app in practice, because the layer diagrams you see in blog posts rarely match what the code looks like.</p>

<p>My current stack for a collaborative project management tool looks like this:</p>

<ul>
<li><strong>UI:</strong> React components that never call <code>fetch()</code> for data reads.</li>
<li><strong>Query layer:</strong> <code>useLiveQuery</code> hooks that subscribe to the local SQLite database and re-render automatically when data changes.</li>
<li><strong>Local database:</strong> SQLite via wa-sqlite, persisted to OPFS.</li>
<li><strong>Mutation layer:</strong> Plain <code>INSERT</code>/<code>UPDATE</code>/<code>DELETE</code> statements against local SQLite.</li>
<li><strong>Sync:</strong> PowerSync managing replication between local SQLite and our Postgres back-end.</li>
<li><strong>Server:</strong> Postgres, a Node.js auth service, and a small sync validation layer.</li>
</ul>

<p>The component code ends up looking almost absurdly simple compared to what I used to write:</p>

<div class="break-out">
<pre><code class="language-typescript">import { useLiveQuery } from '@powersync/react';
import { db } from '../lib/database';

function TaskBoard({ projectId }: { projectId: string }) {
  const tasks = useLiveQuery(
    `SELECT &#42; FROM tasks WHERE project&#95;id = ? AND archived = 0 ORDER BY position`,
    [projectId]
  );

  async function addTask(title: string) {
    await db.execute(
      `INSERT INTO tasks (id, title, project&#95;id, position, created&#95;at)
       VALUES (?, ?, ?, ?, datetime('now'))`,
      [crypto.randomUUID(), title, projectId, tasks.length]
    );
    // That's it. useLiveQuery picks up the change automatically.
    // No invalidation, no refetch, no loading state.
  }

  // No isLoading check. Data is local. It's always there after the first sync.
  return (
    &lt;div&gt;
      {tasks.map(task =&gt; &lt;TaskCard key={task.id} task={task} /&gt;)}
      &lt;NewTaskInput onSubmit={addTask} /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
</div>

<p>Compare that to the React Query + REST equivalent, which would be at least twice the code and include loading states, error states, optimistic update logic with rollback, and cache invalidation. I don’t miss it.</p>

<h3 id="auth-in-a-local-first-world">Auth In A Local-First World</h3>

<p>Authentication works roughly the same as traditional apps: JWT tokens, OAuth flows, and session management. The token authenticates the sync connection rather than every individual request. Offline access works because the data is already local. The user was authenticated when the data was originally synced.</p>

<p>Authorization is trickier, and I think most local-first articles under-explain this. You cannot sync your entire database to every client and rely on client-side code to hide unauthorized data. Someone will open DevTools, find the local SQLite file, and see everything. The client is not a trust boundary.</p>

<p>You enforce authorization at the sync layer. PowerSync has “sync rules” that define which rows go to which clients. ElectricSQL has “shapes.” Either way, the server only sends data that the user is authorized to see. When the client sends writes back, the server validates them against authorization rules before applying them to Postgres. If a user tries to modify something they shouldn’t, the server rejects it during sync.</p>

<p>I also want to mention <strong>end-to-end encryption (E2EE)</strong>, because it pairs naturally with local-first. Since data lives on the client, you can encrypt it before sync. The server stores and relays encrypted blobs it can’t read. Apps like Anytype do this. We haven’t implemented E2EE in our current app, but it’s on the roadmap for when we handle more sensitive data.</p>

<h3 id="schema-migrations-on-a-thousand-devices">Schema Migrations On A Thousand Devices</h3>

<p>This one caught me off guard the first time. On the server, you run a migration against one database you control. On the client, every user has their own database that might be running any version of your schema, depending on when they last opened the app.</p>

<p>I use a simple migration runner that checks a version number at app startup:</p>

<div class="break-out">
<pre><code class="language-typescript">const MIGRATIONS = [
  {
    version: 1,
    sql: `
      CREATE TABLE IF NOT EXISTS tasks (
        id TEXT PRIMARY KEY,
        title TEXT NOT NULL,
        status TEXT DEFAULT 'backlog',
        project&#95;id TEXT NOT NULL,
        created&#95;at TEXT DEFAULT (datetime('now'))
      );
    `
  },
  {
    version: 2,
    // Added priority and due&#95;date in sprint 4
    sql: `
      ALTER TABLE tasks ADD COLUMN priority INTEGER DEFAULT 0;
      ALTER TABLE tasks ADD COLUMN due&#95;date TEXT;
    `
  },
  {
    version: 3,
    // Denormalized assignee name for offline display.
    // Yes, I know this is a trade-off. The JOIN was killing
    // performance on low-end Android devices.
    sql: `
      ALTER TABLE tasks ADD COLUMN assignee&#95;name TEXT DEFAULT '';
    `
  }
];

async function runMigrations(db: Database) {
  await db.execute(`
    CREATE TABLE IF NOT EXISTS &#95;schema&#95;version (version INTEGER)
  `);

  const rows = await db.execute('SELECT version FROM &#95;schema&#95;version');
  const currentVersion = rows.length &gt; 0 ? rows[0].version : 0;

  for (const migration of MIGRATIONS) {
    if (migration.version &gt; currentVersion) {
      console.log(`Migrating local DB to v${migration.version}`);
      await db.execute('BEGIN');
      try {
        await db.execute(migration.sql);
        await db.execute(
          'INSERT OR REPLACE INTO &#95;schema&#95;version (rowid, version) VALUES (1, ?)',
          [migration.version]
        );
        await db.execute('COMMIT');
      } catch (err) {
        await db.execute('ROLLBACK');
        // In production, this fires a Sentry alert with the
        // migration version and error details
        throw err;
      }
    }
  }
}
</code></pre>
</div>

<p><strong>Design your migrations to be additive.</strong> New columns with defaults. New tables. Don’t rename or drop columns unless you absolutely must, because users running old app versions will still be syncing data, and your server needs to handle the mismatch. I learned this the hard way when I dropped a column that an older client was still writing to, which caused silent sync failures for about 200 users over a weekend. Not fun.</p>

<h2 id="if-i-were-starting-a-new-project-today">If I Were Starting A New Project Today</h2>

<p>I get asked this a lot, so here’s my current answer. It changes every six months or so.</p>

<p>For a collaborative app with real-time features and offline support, I’d start with: <strong>React</strong> on the front end, <strong>PowerSync</strong> for sync, <strong>SQLite via wa-sqlite</strong> on the client (persisted to OPFS with IndexedDB fallback for Safari), and <strong>Supabase</strong> (which gives me Postgres, auth, and row-level security out of the box). I’d use <strong>Yjs</strong> only if I needed rich text collaboration, and I’d avoid it if I didn’t, because CRDTs add meaningful complexity to your data model.</p>

<p>For a simpler app where I mostly need offline support and instant reads but collaboration is secondary, I might skip the sync engine entirely and just use a local SQLite database with a custom sync layer that pushes/pulls from a REST API. I know that sounds like reinventing the wheel, but for simple cases, a custom sync that you fully understand is better than a general-purpose sync engine that adds concepts you don’t need.</p>

<p>I would not currently use ElectricSQL or Zero for production, not because they’re bad, but because I want another 6-12 months of maturity before I’d trust them for something I’m on-call for. I’ve been burned before by building on early-stage infrastructure (I was an early <a href="https://docs.meteor.com">Meteor</a> adopter, if that tells you anything) and I’m more cautious now about where I accept novelty risk.</p>

<h2 id="performance-what-s-actually-fast-and-what-hurts">Performance: What’s Actually Fast And What Hurts</h2>

<p>Reads are instant. That’s not marketing. Querying a local SQLite database for a list of 500 tasks takes under two milliseconds on my M2 MacBook and about eight milliseconds on a mid-range Android phone. No network. No spinner. No loading state.</p>

<p>Writes are instant, too. <code>INSERT INTO tasks</code> runs locally, the UI updates reactively, and sync happens whenever. Users perceive writes as instantaneous because they are.</p>

<p>Initial sync is where you pay the cost. Bootstrapping the local replica on first load (or on a new device) means downloading potentially megabytes of data. In our app, a workspace with 5,000 tasks, 200 projects, and 50 users takes about 1.2 seconds on broadband and four to five seconds on a slow mobile connection. We mitigate this with partial sync (only sync the user’s active projects) and by showing a one-time “Setting up your workspace” screen during the first sync. After that initial sync, incremental updates are tiny.</p>

<p>Bundle size is a real concern. SQLite compiled to WASM adds roughly 400KB gzipped to your JavaScript bundle. That’s not trivial, especially if you care about Time to Interactive on mobile. I lazy-load the database module with dynamic <code>import()</code> so it doesn’t block the initial render.</p>

<p>Memory is the other gotcha. SQLite WASM runs in memory, and on mobile browsers with aggressive memory limits, a large database can cause tab crashes. I haven’t found a great solution for this beyond keeping the synced dataset small through partial sync and being aggressive about pruning old data.</p>

<p><strong>Note</strong>: Speaking of memory issues, I’ve been reading <a href="https://www.oreilly.com/library/view/designing-data-intensive-applications/9781491903063/"><em>Designing Data-Intensive Applications</em></a> by Martin Kleppmann for the third time. Every re-read, I catch something new. If you haven’t read it and you’re thinking about distributed data, just stop and read it first.</p>

<h2 id="testing-this-stuff">Testing This Stuff</h2>

<p>I’ll keep this brief because the honest answer is that testing local-first apps is harder than testing traditional apps, and the tooling isn’t great yet.</p>

<p>What works for me: unit tests for merge logic (these are pure functions, easy to test), integration tests that spin up two client instances in memory and verify they converge after concurrent edits, and Playwright E2E tests that use <code>context.setOffline(true)</code> to simulate offline/online transitions.</p>

<p>What I haven’t figured out well: reproducing bugs that only happen during conflict resolution with specific timing. When a user reports that a task <em>“lost its description,”</em> I often can’t reproduce it because I don’t know exactly what sequence of offline edits and sync events led to the conflict. I’ve started logging sync events in more detail (what was sent, what was received, what conflicts were detected, how they were resolved) and shipping those logs to our observability stack. It helps, but it’s not as clean as I’d like.</p>

<p>Property-based testing with something like fast-check is genuinely useful for CRDT logic. Generate random operation sequences, apply them in random orders, and assert convergence. I wish I’d started doing this earlier.</p>

<h2 id="what-i-m-watching-what-worries-me">What I’m Watching, What Worries Me</h2>

<p>I’m excited about where this is going. PGlite (full Postgres in the browser) feels like a glimpse of a future where the client/server data layer distinction just dissolves. You write SQL, it runs everywhere, sync is a runtime concern rather than an architectural decision. We’re not there yet, but you can see it from here.</p>

<p>I’m also watching the convergence of local-first and AI. Running models locally, keeping data on-device, using cloud AI only with explicit consent, and encrypted data. The privacy implications are compelling, and I think <em>“your data never leaves your device”</em> will become a real product differentiator as AI eats more of the software experience.</p>

<p>What worries me is <strong>fragmentation</strong>. Every sync engine uses its own protocol. There’s no standard. If ElectricSQL shuts down (it won’t, probably, but <em>if</em>), migrating to PowerSync isn’t trivial. I abstract my sync layer partly for this reason, but it still makes me nervous.</p>

<blockquote class="pull-quote">
  <p>
    <a class="pull-quote__link" aria-label="Share on Twitter" href="https://twitter.com/share?text=%0aThe%20web%20has%20standards%20for%20nearly%20everything.%20We%20don%e2%80%99t%20have%20one%20for%20sync,%20and%20I%20don%e2%80%99t%20see%20one%20emerging%20soon.%0a&url=https://smashingmagazine.com%2f2026%2f05%2farchitecture-local-first-web-development%2f">
      
The web has standards for nearly everything. We don’t have one for sync, and I don’t see one emerging soon.

    </a>
  </p>
  <div class="pull-quote__quotation">
    <div class="pull-quote__bg">
      <span class="pull-quote__symbol">“</span></div>
  </div>
</blockquote>

<p>I’m also worried about the <strong>complexity budget</strong>. Local-first adds real architectural complexity: sync engines, conflict resolution, client-side migrations, partial replication, and auth at the sync boundary. For a team of experienced developers building the right kind of app, that complexity pays for itself many times over. For a team that just needs a CRUD app, it’s a trap.</p>

<p>I keep coming back to something a developer named Kevin said to me at a local-first meetup in Berlin last year:</p>

<blockquote>“The best architecture is the one your team can debug at 2 AM.”</blockquote>

<p>He’s right. If local-first makes your app faster, more reliable, and better for users, and your team understands how the sync works, go for it. If you’re adding it because it sounds cool and you don’t fully understand the failure modes yet, build a prototype first. Learn where it breaks. Then decide.</p>

<p>I’m building my fourth local-first app right now: a collaborative planning tool for small teams, with offline support and optional E2E encryption. It’s the most ambitious thing I’ve attempted with this architecture. I’ll write about how it goes.</p>

<p>If you’re starting out, pick one feature in your current app that would benefit from instant local reads and offline writes. Add a local SQLite database. Wire up reactive queries. See how it feels. I think you’ll have the same reaction I did: oh, <em>this</em> is how it should have always worked.</p>

<h2 id="further-reading">Further Reading</h2>

<ul>
<li>“<a href="https://www.inkandswitch.com/local-first/">Local-First Software</a>” (Ink &amp; Switch): This is still the best starting point.</li>
<li>“<a href="https://www.youtube.com/watch?v=PMVBuMK_pJY">CRDTs: The hard parts”</a>” (Martin Kleppmann, video): Martin’s talks on CRDTs are excellent.</li>
<li>The <a href="https://localfirstweb.dev/">localfirstweb.dev</a> community site: A good directory of tools.</li>
<li><a href="https://docs.powersync.com/">PowerSync Documentation</a></li>
<li><a href="https://electric-sql.com/docs">ElectricSQL Documentation</a></li>
<li><a href="https://docs.yjs.dev/">Yjs Documentation</a></li>
<li><a href="https://automerge.org/docs/hello/">Automerge Documentation</a></li>
</ul>

<div class="signature">
  <img src="https://www.smashingmagazine.com/images/logo/logo--red.png" alt="Smashing Editorial" width="35" height="46" loading="lazy" decoding="async" />
  <span>(gg, yk)</span>
</div>


              </article>
            </body>
          </html>
        ]]></content:encoded></item><item><author>Joas Pambou</author><title>Designing Stable Interfaces For Streaming Content</title><link>https://www.smashingmagazine.com/2026/05/designing-stable-interfaces-streaming-content/</link><pubDate>Fri, 01 May 2026 08:00:00 +0000</pubDate><guid>https://www.smashingmagazine.com/2026/05/designing-stable-interfaces-streaming-content/</guid><description>Streaming UIs are an easy concept on the surface, but are quite complicated in practice. There are many considerations that need to be accounted for, from layout shifts and motion preferences to proper markup and various states, that may not be instantly obvious. What happens if the stream is interrupted? Can users tab through the UI on the keyboard as it shifts? What ARIA attributes might be needed? Those are the sorts of things we will tackle in this article.</description><content:encoded><![CDATA[
          <html>
            <head>
              <meta charset="utf-8">
              <link rel="canonical" href="https://www.smashingmagazine.com/2026/05/designing-stable-interfaces-streaming-content/" />
              <title>Designing Stable Interfaces For Streaming Content</title>
            </head>
            <body>
              <article>
                <header>
                  <h1>Designing Stable Interfaces For Streaming Content</h1>
                  
                    
                    <address>Joas Pambou</address>
                  
                  <time datetime="2026-05-01T08:00:00&#43;00:00" class="op-published">2026-05-01T08:00:00+00:00</time>
                  <time datetime="2026-05-01T08:00:00&#43;00:00" class="op-modified">2026-05-12T16:11:33+00:00</time>
                </header>
                
                

<p>More interfaces now render while the response is still being generated. The UI begins in one state, then updates as more data comes in. You see this in chat apps, logs, transcription tools, and other real-time systems.</p>

<p>The tricky part is that the <strong>interface is not in a fixed state</strong>; it keeps changing as new content comes in. It grows where lines become longer and new blocks appear. Something that was just below the screen can suddenly move, and the user’s scroll position becomes harder to manage. Parts of the UI might even be incomplete while the user is already interacting with it.</p>

<p>In this article, we’ll take a simple interface and make it handle this properly. We’ll look at how to keep things stable, manage scrolling, and render partial content without breaking the reading experience.</p>

<h2 id="what-does-a-streaming-ui-actually-look-like">What Does A Streaming UI Actually Look Like?</h2>

<p>I’ve built three demos that stream content in different ways: a chat bubble, a log feed, and a transcription view. They look different on the surface, but they all run into the same three problems.</p>

<p>The first is <strong>scroll</strong>. When content is streaming in, most interfaces keep the viewport pinned to the bottom. That works if you are just watching, but the moment you scroll up to read something, the page snaps back down. You did not ask for that. The interface decided for you, and now you’re fighting it instead of reading.</p>

<p>The second is <strong>layout shift</strong>. Streaming content means containers are constantly growing, and as they do, everything below shifts downward. A button you were about to click is no longer where it was. A line you were reading has moved. The page is not broken; it is just that nothing stays still long enough to interact with comfortably.</p>

<p>The third is <strong>render frequency</strong>. Browsers paint the screen around 60 times per second, but streams can arrive much faster than that. This means the DOM, which is the browser’s internal representation of everything on the page, ends up being updated for frames the user will never actually see. Each update still costs something, and that cost adds up quietly until performance starts to slip.</p>

<p>As you go through each demo, pay attention to where things start feeling off. That small moment of friction when the interface starts getting in your way. This is exactly what we are here to fix.</p>

<div data-audience="non-subscriber" data-remove="true" class="feature-panel-container">

<aside class="feature-panel" style="">
<div class="feature-panel-left-col">

<div class="feature-panel-description"><p>Meet <strong><a data-instant href="https://www.smashingconf.com/online-workshops/">Smashing Workshops</a></strong> on <strong>front-end, design &amp; UX</strong>, with practical takeaways, live sessions, <strong>video recordings</strong> and a friendly Q&amp;A. With Brad Frost, Stéph Walter and <a href="https://smashingconf.com/online-workshops/workshops">so many others</a>.</p>
<a data-instant href="smashing-workshops" class="btn btn--green btn--large" style="">Jump to the workshops&nbsp;↬</a></div>
</div>
<div class="feature-panel-right-col"><a data-instant href="smashing-workshops" class="feature-panel-image-link">
<div class="feature-panel-image">
<img
    loading="lazy"
    decoding="async"
    class="feature-panel-image-img"
    src="/images/smashing-cat/cat-scubadiving-panel.svg"
    alt="Feature Panel"
    width="257"
    height="355"
/>

</div>
</a>
</div>
</aside>
</div>

<h2 id="example-1-streaming-ai-chat-responses">Example 1: Streaming AI Chat Responses</h2>

<p>This is the most familiar case. You click <strong>Stream</strong>, and the message starts growing token by token, just like a typical AI chat interface.</p>














<figure class="
  
  
  ">
  
    <a href="https://codesandbox.io/embed/swmjpl?view=preview">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="566"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/1-streaming-ai-chat-responses.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/1-streaming-ai-chat-responses.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/1-streaming-ai-chat-responses.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/1-streaming-ai-chat-responses.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/1-streaming-ai-chat-responses.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/1-streaming-ai-chat-responses.png"
			
			sizes="100vw"
			alt="Streaming AI Chat Responses"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Open in <a href='https://codesandbox.io/embed/swmjpl?view=preview'>CodeSandbox</a>. (<a href='https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/1-streaming-ai-chat-responses.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>Here’s what I want you to try:</p>

<ul>
<li>Click the <strong>Stream</strong> button.</li>
<li>Try scrolling upwards while the message is streaming.</li>
<li>Increase the speed (to something like 10ms).</li>
</ul>

<p>You will notice something subtle but important: the UI keeps trying to pull you back down. Basically, it is making a decision for you about where your attention should be.</p>

<p>That’s one example. Let’s look at another.</p>

<h2 id="example-2-live-processing-in-a-log-viewer">Example 2: Live Processing In A Log Viewer</h2>

<p>This example looks different on the surface, but the problem is actually very similar to the first example. Rather than a message that gets longer over time, new lines are appended continuously, like a terminal or a log stream.</p>

<p>The interesting part here is the tail toggle. It makes the trade-off between interaction and stable interfaces very clear:</p>














<figure class="
  
  
  ">
  
    <a href="https://codesandbox.io/embed/cytscf?view=preview">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="515"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/2-live-processing-log-viewer.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/2-live-processing-log-viewer.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/2-live-processing-log-viewer.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/2-live-processing-log-viewer.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/2-live-processing-log-viewer.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/2-live-processing-log-viewer.png"
			
			sizes="100vw"
			alt="Live Processing In A Log Viewer"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Open in <a href='https://codesandbox.io/embed/cytscf?view=preview'>CodeSandbox</a>. (<a href='https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/2-live-processing-log-viewer.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>Again, here is what I want you to try:</p>

<ul>
<li>Click the <strong>Start</strong> button.</li>
<li>Allow the logs to stream past the container’s height.</li>
<li>Scroll up to the beginning.</li>
<li>Stop the stream and disable the “tail” option.</li>
</ul>

<p>Notice that, when tail is enabled, the UI follows the new content. But you’re unable to scroll up and stay in place. Instead, you need to stop the stream or enable “tail” to explore the content.</p>

<h2 id="example-3-dashboard-displaying-real-time-metrics">Example 3: Dashboard Displaying Real-Time Metrics</h2>

<p>In this case, the UI updates in place:</p>

<ul>
<li>Numbers change,</li>
<li>Charts shift,</li>
<li>Values refresh continuously.</li>
</ul>














<figure class="
  
  
  ">
  
    <a href="https://codesandbox.io/embed/8rtsrm?view=preview">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="402"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/3-dashboard-display-real-time-metrics.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/3-dashboard-display-real-time-metrics.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/3-dashboard-display-real-time-metrics.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/3-dashboard-display-real-time-metrics.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/3-dashboard-display-real-time-metrics.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/3-dashboard-display-real-time-metrics.png"
			
			sizes="100vw"
			alt="Dashboard Displaying Real-Time Metrics"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Open in <a href='https://codesandbox.io/embed/8rtsrm?view=preview'>CodeSandbox</a>. (<a href='https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/3-dashboard-display-real-time-metrics.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>There is no scroll tension this time, but a different issue shows up. That’s what we’ll get into next.</p>

<h2 id="why-the-ui-feels-unstable-and-how-to-fix-it">Why The UI Feels Unstable And How To Fix It</h2>

<p>If you tried the chat demo and scrolled upward while the responses were coming in, you may have spotted the first issue right away: the UI keeps pulling you back down to the latest streamed content as it updates. This takes you out of context and never allows you the time to fully digest the content once it has passed.</p>

<p>We see that exact same issue in the second example, the log viewer. Without the tail toggle, the streamed content overrides your scroll position.</p>

<p>These aren’t bugs in the traditional sense that they produce code errors; rather, they are accessibility issues that affect <em>all</em> users. That said, they can be fixed and prevented with careful UX considerations as you plan and test your work.</p>

<h3 id="ensure-predictable-scroll-behavior">Ensure Predictable Scroll Behavior</h3>

<p>This is the goal:</p>

<ul>
<li>Enable auto-scrolling when detecting that the user is at the bottom of the stream.</li>
<li>Stop auto-scrolling when the user has scrolled upwards.</li>
<li>Resume auto-scrolling if the user scrolls back to the bottom of the stream.</li>
</ul>

<p>To do that, we need to know whether the user has intentionally moved away from the bottom, which we can assume is true when the scroll position is manually changed. We can track that behavior with a flag.</p>

<pre><code class="language-javascript">let userScrolled = false;

chatEl.addEventListener('scroll', () =&gt; {
  const gap = chatEl.scrollHeight
            - chatEl.scrollTop
            - chatEl.clientHeight;

  userScrolled = gap &gt; 60;
});
</code></pre>

<p>That <code>60px</code> threshold matters. Without it, tiny layout changes (like a new line) would briefly create a gap and break auto-scroll, even if the user didn’t actually scroll.</p>

<p>Now let’s make sure that we enable auto-scrolling only when the user’s scroll position is equal to the stream’s scroll height, i.e., the user is at the bottom of the stream:</p>

<pre><code class="language-javascript">function autoScroll() {
  if (!userScrolled) {
    chatEl.scrollTop = chatEl.scrollHeight;
  }
}
</code></pre>

<p>One small thing that’s easy to miss: we need to reset <code>userScrolled</code> once a new stream begins. Otherwise, one scroll from a previous message can silently disable auto-scroll for the next one.</p>

<h3 id="solidify-layout-stability">Solidify Layout Stability</h3>

<p>We saw this in the first example as well. As new content streams in, the layout jumps, or shifts, taking you out of your current context. To be specific about what’s shifting: it’s not the page layout in a broad sense, it’s the content directly below the chat bubble.</p>

<p>There’s also a subtler artifact worth calling out before we look at the code: cursor flicker. Because we’re wiping <code>innerHTML</code> and recreating every element on every tick, the cursor is being destroyed and re-added constantly, up to 80 times per second at fast speeds.</p>

<p>At normal speed, it’s easy to miss, but slow the slider down to around 30ms, and you’ll see a faint but persistent flicker at the end of the text. Once we fix the rebuild pattern, the flicker disappears entirely.</p>


<figure class="video-embed-container break-out">
  <div class="video-embed-container--wrapper"
	
  >
    <iframe class="video-embed-container--wrapper-iframe" src="https://player.vimeo.com/video/1187532028"
        frameborder="0"
        allow="autoplay; fullscreen; picture-in-picture"
        allowfullscreen>
    </iframe>
	</div>
	
</figure>

<p>That rebuild pattern is right here; this is what runs on every single incoming character:</p>

<pre><code class="language-javascript">bubble.innerHTML = '';

fullText.split('\n').forEach(line =&gt; {
  const p = document.createElement('p');
  p.textContent = line || '\u00A0';
  bubble.appendChild(p);
});

bubble.appendChild(cursorEl);
</code></pre>

<p>This works, but it’s expensive. Every update wipes the DOM and rebuilds it, forcing layout recalculation each time.</p>

<p>Now we write directly into a live node:</p>

<pre><code class="language-javascript">let currentP = null;

function initBubble(bubble, cursor) {
  currentP = document.createElement('p');
  currentP.appendChild(document.createTextNode(''));
  bubble.insertBefore(currentP, cursor);
}
</code></pre>

<p>What we can do next is to create one paragraph with an empty text node and insert it before the cursor. That gives us a live node we can write into directly.</p>

<p>Then, for each character that arrives:</p>

<pre><code class="language-javascript">function appendChar(char, bubble, cursor) {
  if (char === '\n') {
    currentP = document.createElement('p');
    currentP.appendChild(document.createTextNode(''));
    bubble.insertBefore(currentP, cursor);
  } else {
    currentP.firstChild.textContent += char;
  }
}
</code></pre>

<p>For a regular character, we extend the text node by one character. The browser doesn’t need to recalculate the layout for that; the text grew, but nothing moved. For a newline, we create a fresh paragraph and move <code>currentP</code> forward. Layout recalculates once for that new paragraph, and that’s it.</p>

<div class="partners__lead-place"></div>

<h3 id="render-frequency">Render Frequency</h3>

<p>This one is most visible in the first example, the chat UI. Even with scrolling and a layout fixed, we’re still writing to the DOM on every single incoming character.</p>

<p>When the stream is moving fast, you end up hammering the DOM with updates that don’t actually matter. The fix is straightforward: hold the incoming text in a buffer instead of writing it out immediately. Once you’ve collected enough, write it all to the DOM in one go; that’s what a <strong>flush</strong> is.</p>

<p>To pull this off, we keep a simple buffer and make sure we only schedule a single update at a time. When it fires, <code>requestAnimationFrame</code> takes everything that has built up and writes it to the DOM in one shot.</p>

<pre><code class="language-javascript">let pending   = '';
let rafQueued = false;
</code></pre>

<p>When a new character streams in, we then add it to the buffer. If no flush is scheduled yet, we queue one:</p>

<pre><code class="language-javascript">function onChar(char) {
  pending += char;

  if (!rafQueued) {
    rafQueued = true;
    requestAnimationFrame(flush);
  }
}
</code></pre>

<p>The <code>rafQueued</code> flag is important. Without it, every character would schedule its own frame, and you’d end up with dozens of unnecessary flushes.</p>

<p>When the flush fires, it drains the entire buffer in one pass:</p>

<pre><code class="language-javascript">function flush() {
  for (const char of pending) {
    appendChar(char);
  }
  pending   = '';
  rafQueued = false;
  autoScroll();
}
</code></pre>

<p>All the characters that arrive after the last frame are then rendered together, right before the browser paints them. Then we clear the buffer, reset the flag, and run auto-scroll once.</p>

<pre><code class="language-javascript">let userScrolled = false;

chatEl.addEventListener('scroll', () =&gt; {
  const gap = chatEl.scrollHeight
            - chatEl.scrollTop
            - chatEl.clientHeight;

  userScrolled = gap &gt; 60;
});

function autoScroll() {
  if (!userScrolled) {
    chatEl.scrollTop = chatEl.scrollHeight;
  }
}
</code></pre>

<p>If the gap is small, we keep auto-scrolling. If it grows, we assume the user scrolled up, and we stop. That small threshold helps avoid jitter when new lines slightly change the height. Also, remember to reset <code>userScrolled</code> when a new stream starts.</p>

<p>Once scrolling is under control, another issue becomes obvious. As the message grows, it keeps shifting:</p>

<ul>
<li>It starts as one line,</li>
<li>It expands, then</li>
<li>It pushes everything below it.</li>
</ul>

<p>Nothing is technically broken, but it doesn’t feel stable. A common approach is to rebuild the whole message on every update:</p>

<pre><code class="language-javascript">bubble.innerHTML = '';

fullText.split('\n').forEach(line =&gt; {
  const p = document.createElement('p');
  p.textContent = line || '\u00A0';
  bubble.appendChild(p);
});

bubble.appendChild(cursorEl);
</code></pre>

<p>This works, but it is doing too much work. Every update destroys and rebuilds the DOM, forcing layout recalculation each time. That’s why everything keeps shifting. The idea is to write into the current paragraph and only create a new one when we actually hit a line break.</p>

<pre><code class="language-javascript">let currentP = null;

function initBubble(bubble, cursor) {
  currentP = document.createElement('p');
  currentP.appendChild(document.createTextNode(''));
  bubble.insertBefore(currentP, cursor);
}
</code></pre>

<p>And then update it character by character:</p>

<pre><code class="language-javascript">function appendChar(char, bubble, cursor) {
  if (char === '\n') {
    currentP = document.createElement('p');
    currentP.appendChild(document.createTextNode(''));
    bubble.insertBefore(currentP, cursor);
  } else {
    currentP.firstChild.textContent += char;
  }
}
</code></pre>

<p>Now we’re no longer rebuilding everything. Most updates just extend a text node, which is cheap and doesn’t trigger large layout shifts. It also fixes the small cursor flicker you might have noticed earlier, since we’re no longer removing and re-adding it.</p>

<p>At this point, the UI already feels better, but there is still something subtle going on. We are still updating the DOM on every character. At higher speeds, that becomes a lot of small updates, many of which you never actually see.</p>

<p>Instead of rendering immediately, we can buffer the incoming characters and apply them once per frame.</p>

<pre><code class="language-javascript">let pending = '';
let rafQueued = false;

function onChar(char) {
  pending += char;

  if (!rafQueued) {
    rafQueued = true;
    requestAnimationFrame(flush);
  }
}
</code></pre>

<p>At this point, we’re not touching the DOM yet, but only collecting characters as they arrive. Then, right before the next frame is painted, we flush everything at once:</p>

<pre><code class="language-javascript">function flush() {
  for (const char of pending) {
    appendChar(char);
  }

  pending = '';
  rafQueued = false;

  autoScroll();
}
</code></pre>

<p>These separate two things that were previously tied together:</p>

<ol>
<li>How fast data arrives, and</li>
<li>When the UI updates.</li>
</ol>

<p>The result looks the same, but the browser does less work, resulting in the UI feeling smoother, especially when the stream is set to a faster speed.</p>














<figure class="
  
  
  ">
  
    <a href="https://codesandbox.io/embed/pk7tk5?view=preview">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="566"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/4-broken-vs-fixed.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/4-broken-vs-fixed.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/4-broken-vs-fixed.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/4-broken-vs-fixed.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/4-broken-vs-fixed.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/4-broken-vs-fixed.png"
			
			sizes="100vw"
			alt="Broken vs. fixed"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Open in <a href='https://codesandbox.io/embed/pk7tk5?view=preview'>CodeSandbox</a>. (<a href='https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/4-broken-vs-fixed.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>None of these changes is a big effort on its own. But once they are in place, the interface stops reacting blindly to every update. It becomes easier to read, easier to control, and a lot less distracting, even though the content is still coming in continuously.</p>

<p>There are even more considerations to take into account for ensuring a stable, predictable, and good user experience. For example, what happens if the stream is canceled mid-flow? And what can we do to ensure that user preferences are respected for things like reduced motion, keyboard navigation, and screen reader accessibility? Let’s get into those next.</p>

<h2 id="handling-interrupted-streams">Handling Interrupted Streams</h2>

<p>Most streaming interfaces include a way to stop or cancel the stream. We saw that in the demos. But stopping often leaves the UI in an awkward state. The cursor might keep blinking, buttons don’t update, and the message just freezes mid-stream with no clear indication that it didn’t finish.</p>

<p>The problem is that the stop is usually wired to do one thing: cancel the timer. That’s not enough. You also need to (1) clear the pending buffer, (2) remove the cursor, (3) mark the response as incomplete, and (4) reset the buttons. Here’s how we accomplish those.</p>

<h3 id="1-stop-the-stream-cleanly">1. Stop The Stream Cleanly</h3>

<p>Here’s what <code>stopStream</code> needs to do, in order:</p>

<ol>
<li>Cancel the timer and flip the <code>isStreaming</code> flag so no more ticks run.</li>
<li>Clear the <code>requestAnimationFrame</code> (RAF) buffer so nothing still queued gets written on the next frame.</li>
</ol>

<pre><code class="language-javascript">function stopStream() {
  clearTimeout(streamTimer);
  isStreaming = false;
  pending     = '';
  rafQueued   = false;
}
</code></pre>

<p>Clearing the <code>pending</code> property matters because there might be characters buffered from the last stream instance that haven’t been flushed yet. If you don’t clear it, the next <code>requestAnimationFrame</code> fires, drains the buffer, and writes those characters to the DOM after the stream has officially stopped.</p>

<p>Now we move on to removing the cursor by calling <code>markStopped</code> on the bubble:</p>

<pre><code class="language-javascript">if (cursorEl && cursorEl.parentNode) cursorEl.remove();
  markStopped(aiBubble);

  stopBtn.style.display  = 'none';
  retryBtn.style.display = '';
  playBtn.style.display  = '';
  setStatus('Stopped', 'stopped');
  chat.removeEventListener('scroll', onScroll);
}
</code></pre>

<p>The <code>cursorEl.parentNode</code> check is there because <code>stopStream</code> is also called internally when a new message fires mid-stream, at which point the cursor might already be gone. Calling <code>remove()</code> on a detached node throws, so we check first.</p>

<p><code>markStopped</code> appends a small label to the bottom of the bubble so the user knows the response didn’t finish:</p>

<pre><code class="language-javascript">function markStopped(bubble) {
  if (!bubble) return;
  bubble.classList.add('stopped');

  const label = document.createElement('span');
  label.className = 'stopped-label';
  label.textContent = 'response stopped';
  bubble.appendChild(label);
}
</code></pre>

<p>The null check on <code>bubble</code> handles the edge case where stop fires before the AI message element has been initialized, which can happen if the user clicks stop during the 300ms delay before the bubble appears.</p>

<h3 id="provide-a-retry-option">Provide A Retry Option</h3>

<p>If the stream simply stops &mdash; perhaps due to a network issue or some other unexpected error &mdash; we ought to provide the user with a path to re-attempt the stream. What that basically means is preventing the UI from doing the expensive work needed to scroll back up to the top, re-read the prompt, and retype it. With a retry option, the user only needs to click a button, and the stream restarts from the current position.</p>

<p>To make that work, we need to hold onto the question when the stream starts:</p>

<pre><code class="language-javascript">let lastQuestion = '';

function startStream(question, answer) {
  lastQuestion = question;
  // rest of setup...
}
</code></pre>

<p>Then, when the retry attempt runs, we reset everything and start fresh:</p>

<pre><code class="language-javascript">function retryStream() {
  if (currentMsgEl && currentMsgEl.parentNode) {
    currentMsgEl.remove();
  }

  charIndex    = 0;
  userScrolled = false;
  pending      = '';
  rafQueued    = false;
  isStreaming  = true;

  retryBtn.style.display = 'none';
  stopBtn.style.display  = '';
  setStatus('Streaming...', 'streaming');

  chat.addEventListener('scroll', onScroll, { passive: true });

  setTimeout(() =&gt; {
    initAIMsg();
    tick(lastAnswer);
  }, 200);
}
</code></pre>

<p>The reset is critical. Every piece of state needs to go back to its initial value, just like a brand new stream.</p>

<p><strong>Note:</strong> We remove the entire message row (<code>currentMsgEl</code>), not just the bubble. If only the bubble is removed, the layout wrapper and avatar remain persistent and break the structure.</p>

<h3 id="send-a-new-message-mid-stream">Send A New Message Mid-Stream</h3>

<p>There’s one more edge case that’s easy to miss. If the user sends a new message while a stream is still running, you end up with two loops writing to the DOM at the same time. The result is messy, and characters from different responses get mixed together.</p>

<p>Here’s what to do: stop the current stream before starting a new one.</p>

<pre><code class="language-javascript">function startStream(question, answer) {
  if (isStreaming) {
    clearTimeout(streamTimer);
    isStreaming = false;
    pending     = '';
    rafQueued   = false;
    if (cursorEl && cursorEl.parentNode) cursorEl.remove();
    chat.removeEventListener('scroll', onScroll);
  }

  // now reset and start fresh
  charIndex    = 0;
  userScrolled = false;
  isStreaming  = true;
  lastQuestion = question;
  // ...
}
</code></pre>

<p>Here, we inline the cleanup rather than calling <code>stopStream</code> directly because <code>stopStream</code> also calls <code>markStopped</code> and resets the buttons. The next demo has all three behaviors wired up. You can start a stream, hit “Stop” mid-stream, and the cursor disappears, the “response stopped” label appears, and a “Retry” buttons displayed.</p>














<figure class="
  
  
  ">
  
    <a href="https://codesandbox.io/embed/9cfy92?view=preview">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="505"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/5-interruptible-stream.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/5-interruptible-stream.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/5-interruptible-stream.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/5-interruptible-stream.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/5-interruptible-stream.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/5-interruptible-stream.png"
			
			sizes="100vw"
			alt="Interruptible stream"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Open in <a href='https://codesandbox.io/embed/9cfy92?view=preview'>CodeSandbox</a>. (<a href='https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/5-interruptible-stream.png'>Large preview</a>)
    </figcaption>
  
</figure>

<div class="partners__lead-place"></div>

<h2 id="accessibility">Accessibility</h2>

<p>Streaming interfaces are often built and tested with a mouse, so they may feel just fine in a browser, but break down in other situations that may not have been considered, like whether a screen reader announces new content at all. Or navigating with a keyboard might get stuck or lose focus as things update. And, of course, moving text can be uncomfortable &mdash; or even disabling &mdash; for <a href="https://www.smashingmagazine.com/2021/10/respecting-users-motion-preferences/">those with motion sensitivities</a>.</p>

<p>The good part is that you do not need to rebuild everything to accommodate these things; they can be fixed with solutions that sit on top of what is already there.</p>

<h3 id="accommodating-assistive-technology-with-live-regions">Accommodating Assistive Technology With Live Regions</h3>

<p>Screen readers don’t automatically announce content that shows up on its own. They usually read things when the user moves to them. So, in a streaming UI, where text builds up over time, nothing gets announced. The content is there, but the user doesn’t hear anything.</p>

<p>The fix is <a href="https://w3c.github.io/aria/#aria-live"><code>aria-live</code></a>. It tells the browser to watch a container and announce updates as they happen, without the user needing to move focus.</p>

<pre><code class="language-html">&lt;div
  id="chat"
  role="log"
  aria-live="polite"
  aria-atomic="false"
  aria-label="Chat messages"
&gt;&lt;/div&gt;
</code></pre>

<ul>
<li><code>role=&quot;log&quot;</code> tells assistive tech this is a stream of updates, like a running transcript. Some tools handle this automatically, but it’s safer to be explicit so behavior stays consistent.</li>
<li><code>aria-atomic=&quot;false&quot;</code> makes sure only the new content is announced. Without it, some screen readers try to read the whole message again on every update, which quickly becomes unusable.</li>
<li><code>aria-live=&quot;polite&quot;</code> queues updates instead of interrupting. Use <code>assertive</code> only for things that really need immediate attention, like errors.</li>
</ul>

<h3 id="handling-incomplete-states">Handling Incomplete States</h3>

<p>Earlier, we inserted a “Response Stopped” label to the message when the stream stops mid-stream. Visually, that’s enough. But for a screen reader, that change needs to be announced.</p>

<p>Since the message is inside a live region with <code>aria-live=&quot;polite&quot;</code>, the label will be automatically announced as new content when it’s added to the DOM. The live region already handles the announcement, so no additional ARIA is needed on the label itself.</p>

<p>The <strong>Retry</strong> button that appears next also needs context. If a screen reader simply says “Retry, button,” it’s not clear what action that refers to. You can fix that by adding an <code>aria-label</code> that includes the original question:</p>

<pre><code class="language-javascript">retryBtn.setAttribute(
  'aria-label',
  `Retry: ${lastQuestion.slice(0, 60)}`
);
</code></pre>

<p>What you can do here is to set this label when the button appears, not on page load:</p>

<pre><code class="language-javascript">retryBtn.style.display = 'inline-block';
retryBtn.setAttribute(
  'aria-label',
  `Retry: ${lastQuestion.slice(0, 60)}`
);
</code></pre>

<p>We also call <code>retryBtn.focus()</code> after stopping. That way, keyboard users don’t have to <code>Tab</code> around with the keyboard to find the next action.</p>

<p><strong>Testing with assistive technology:</strong> Don’t rely on assumptions about how screen readers announce this. Test with actual tools like NVDA (Windows), JAWS (Windows), or VoiceOver (Mac/iOS). Browser DevTools can show you what’s exposed in the accessibility tree, but they can’t tell you how the content <em>sounds</em>. A real screen reader will reveal whether the announcement is happening at the right time and in the right way.</p>

<h3 id="account-for-keyboard-navigation">Account For Keyboard Navigation</h3>

<p>The controls need to work with the keyboard while the UI is live, so the Stop button has to be reachable. For someone not using a mouse, <kbd>Tab</kbd> + <kbd>Enter</kbd> is the only way to cancel a running stream.</p>

<p>Using <code>display: none</code> is fine for hiding buttons; it removes them from the tab order. The problem is using things like <code>opacity: 0</code> or <code>visibility: hidden</code>. Those hide elements visually, but they can still receive focus, so users end up tabbing onto something they can’t see.</p>

<p>Use <code>:focus-visible</code> so the focus ring shows up for keyboard navigation, but not for mouse clicks:</p>

<pre><code class="language-css">btn:focus-visible {
  outline: 2px solid &#35;1d9e75;
  outline-offset: 2px;
}
</code></pre>

<p>The cursor inside the message should have <code>aria-hidden=&quot;true&quot;</code>. It’s just visual. Without that, some screen readers try to read it as text, which gets distracting.</p>

<h3 id="motion-sensitivity">Motion Sensitivity</h3>

<p>The typewriter effect we see in practically every AI interface produces constant motion. As we’ve already discussed, certain amounts of motion can be disabling. Thankfully, browsers expose <code>prefers-reduced-motion</code>, which detects a user’s motion preferences at the operating system level.</p>

<p>For streaming, the best approach is simple: skip the animation and render the full response at once. The content stays the same, only without the motion.</p>

<pre><code class="language-javascript">const reducedMotion = window.matchMedia(
  '(prefers-reduced-motion: reduce)'
).matches;
</code></pre>

<pre><code class="language-javascript">if (reducedMotion) {
  initAIMsg();
  for (const char of text) appendChar(char);
  if (cursorEl && cursorEl.parentNode) cursorEl.remove();
  done();
  return;
}
tick(text); // normal animation
</code></pre>

<p>In CSS, the cursor blink also needs to stop. Despite being a minor detail, a blinking cursor element counts as <a href="https://www.w3.org/WAI/WCAG21/Understanding/three-flashes-or-below-threshold.html">flashing content</a>.</p>

<pre><code class="language-css">@media (prefers-reduced-motion: reduce) {
  .cursor { animation: none; opacity: 1; }
}
</code></pre>

<p>There we go! The demo below puts everything from this article together, so you can see how these patterns work in practice. It also includes a reduced motion toggle, so you can test the instant render version easily.</p>














<figure class="
  
  
  ">
  
    <a href="https://codesandbox.io/embed/vd9mnk?view=preview">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="594"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/6-accessible-streaming.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/6-accessible-streaming.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/6-accessible-streaming.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/6-accessible-streaming.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/6-accessible-streaming.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/6-accessible-streaming.png"
			
			sizes="100vw"
			alt="Accessible streaming"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Open in <a href='https://codesandbox.io/embed/vd9mnk?view=preview'>CodeSandbox</a>. (<a href='https://files.smashing.media/articles/designing-stable-interfaces-streaming-content/6-accessible-streaming.png'>Large preview</a>)
    </figcaption>
  
</figure>

<h2 id="conclusion">Conclusion</h2>

<p>Streaming itself is mostly solved. Getting data from the server to the client is not the hard part anymore. What breaks is the UI on top of it.</p>

<p>When content updates continuously, small things start to matter, like scroll behavior, layout stability, render timing, and how the interface responds to user actions. If those aren’t handled well, the UI feels unstable and hard to use.</p>

<p>The patterns in this article fix that by:</p>

<ul>
<li>Keeping scroll position under the user’s control,</li>
<li>Updating only what has changed,</li>
<li>Batching renders per frame,</li>
<li>Handling stop and retry actions, and</li>
<li>Making the interface accessible.</li>
</ul>

<p>You don’t need all of these every time. But when streaming is involved, these are the places things usually go wrong.</p>

<h3 id="further-reading">Further Reading</h3>

<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events">Using Server-Sent Events</a><br />
How to open a connection, handle events, and reconnect when needed. This is the transport layer, everything here builds on.</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Streams_API">Streams API</a><br />
Streaming data directly from <code>fetch</code>. Useful when you need more control than SSE.</li>
<li><a href="https://developer.chrome.com/docs/devtools/performance">Chrome DevTools Performance panel</a><br />
Helps you see layout recalculations and paint costs, so you can verify performance improvements.</li>
<li>“<a href="https://web.dev/articles/dom-size-and-interactivity">How Large DOM Sizes Affect Interactivity, And What You Can Do About It</a>”, Jeremy Wagner<br />
Why large DOM trees slow things down, and how to keep them under control in long streaming sessions.</li>
</ul>

<div class="signature">
  <img src="https://www.smashingmagazine.com/images/logo/logo--red.png" alt="Smashing Editorial" width="35" height="46" loading="lazy" decoding="async" />
  <span>(yk)</span>
</div>


              </article>
            </body>
          </html>
        ]]></content:encoded></item><item><author>Joe Attardi</author><title>Moving From Moment.js To The JS Temporal API</title><link>https://www.smashingmagazine.com/2026/03/moving-from-moment-to-temporal-api/</link><pubDate>Fri, 13 Mar 2026 13:00:00 +0000</pubDate><guid>https://www.smashingmagazine.com/2026/03/moving-from-moment-to-temporal-api/</guid><description>The way JavaScript handles time has evolved significantly, from the built-in &lt;code>Date&lt;/code> API to Moment.js and now Temporal. The new standard fills gaps in the original &lt;code>Date&lt;/code> API while addressing limitations found in Moment and other libraries. Joe Attardi shares practical “recipes” for migrating Moment-based code to the new Temporal API.</description><content:encoded><![CDATA[
          <html>
            <head>
              <meta charset="utf-8">
              <link rel="canonical" href="https://www.smashingmagazine.com/2026/03/moving-from-moment-to-temporal-api/" />
              <title>Moving From Moment.js To The JS Temporal API</title>
            </head>
            <body>
              <article>
                <header>
                  <h1>Moving From Moment.js To The JS Temporal API</h1>
                  
                    
                    <address>Joe Attardi</address>
                  
                  <time datetime="2026-03-13T13:00:00&#43;00:00" class="op-published">2026-03-13T13:00:00+00:00</time>
                  <time datetime="2026-03-13T13:00:00&#43;00:00" class="op-modified">2026-05-12T16:11:33+00:00</time>
                </header>
                
                

<p>Almost any kind of application written in JavaScript works with times or dates in some capacity. In the beginning, this was limited to the built-in <code>Date</code> API. This API includes basic functionality, but is quite limited in what it can do.</p>

<p>Third-party libraries like Moment.js, and later built-in APIs such as the <code>Intl</code> APIs and the new <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal">Temporal API</a>, add much greater flexibility to working with times and dates.</p>

<h2 id="the-rise-and-fall-of-moment-js">The Rise And Fall Of Moment.js</h2>

<p><a href="https://momentjs.com">Moment.js</a> is a JavaScript library with powerful utilities for working with times and dates. It includes missing features from the basic <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date">Date API</a>, such as time zone manipulation, and makes many common operations simpler. Moment also includes functions for formatting dates and times. It became a widely used library in many different applications.</p>

<p>However, Moment also had its share of issues. It’s a large library, and can add significantly to an application’s bundle size. Because the library doesn’t support tree shaking (a feature of modern bundlers that can remove unused parts of libraries), the entire Moment library is included even if you only use one or two of its functions.</p>

<p>Another issue with Moment is the fact that the objects it creates are <em>mutable</em>. Calling certain functions on a Moment object has side effects and mutates the value of that object. This can lead to unexpected behavior or bugs.</p>

<p>In 2020, the maintainers of Moment decided to put the library into maintenance mode. No new feature development is being done, and the maintainers recommend against using it for new projects.</p>

<p>There are other JavaScript date libraries, such as <code>date-fns</code>, but there’s a new player in town, an API built directly into JavaScript: <strong>Temporal</strong>. It’s a new standard that fills in the holes of the original <code>Date</code> API as well as solves some of the limitations found in Moment and other libraries.</p>

<div data-audience="non-subscriber" data-remove="true" class="feature-panel-container">

<aside class="feature-panel" style="">
<div class="feature-panel-left-col">

<div class="feature-panel-description"><p>Meet <strong><a data-instant href="/printed-books/touch-design-for-mobile-interfaces/">Touch Design for Mobile Interfaces</a></strong>, Steven Hoober’s brand-new guide on <strong>designing for mobile</strong> with proven, universal, human-centric guidelines. <strong>400 pages</strong>, jam-packed with in-depth user research and <strong>best practices</strong>.</p>
<a data-instant href="https://www.smashingmagazine.com/printed-books/touch-design-for-mobile-interfaces/" class="btn btn--green btn--large" style="">Jump to table of contents&nbsp;↬</a></div>
</div>
<div class="feature-panel-right-col"><a data-instant href="https://www.smashingmagazine.com/printed-books/touch-design-for-mobile-interfaces/" class="feature-panel-image-link">
<div class="feature-panel-image"><picture><source type="image/avif" srcSet="https://archive.smashing.media/assets/344dbf88-fdf9-42bb-adb4-46f01eedd629/14bcab88-b622-47f6-a51d-76b0aa003597/touch-design-book-shop-opt.avif" />
<img
    loading="lazy"
    decoding="async"
    class="feature-panel-image-img"
    src="https://archive.smashing.media/assets/344dbf88-fdf9-42bb-adb4-46f01eedd629/b14658fc-bb2d-41a6-8d1a-70eaaf1b8ec8/touch-design-book-shop-opt.png"
    alt="Feature Panel"
    width="480"
    height="697"
/>
</picture>
</div>
</a>
</div>
</aside>
</div>

<h2 id="what-is-temporal">What Is Temporal?</h2>

<p>Temporal is a new time and date API being added to the ECMAScript standard, which defines modern JavaScript. As of March 2026, it has reached Stage 4 of the TC39 process (the committee that oversees proposals and additions to the JavaScript language), and will be included in the next version of the ECMAScript specification. It has already been implemented in several browsers: <a href="https://developer.chrome.com/release-notes/144">Chrome</a> <a href="https://developer.chrome.com/release-notes/144">144+</a> and <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/139#javascript">Firefox</a> <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/139#javascript">139+</a>, with <a href="https://bugs.webkit.org/show_bug.cgi?id=223166">Safari expected to follow soon</a>. A <a href="https://github.com/js-temporal/temporal-polyfill">polyfill is also available</a> for unsupported browsers and Node.js.</p>

<p>The Temporal API creates objects that, generally, represent moments in time. These can be full-time and date stamps in a given time zone, or they can be a generic instance of “wall clock” time without any time zone or date information. Some of the main features of Temporal include:</p>

<ul>
<li><strong>Times with or without dates.</strong><br />
A Temporal object can represent a specific time on a specific date, or a time without any date information. A specific date, without a time, can also be represented.</li>
<li><strong>Time zone support.</strong><br />
Temporal objects are fully time zone aware and can be converted across different time zones. Moment supports time zones, too, but it requires the additional <code>moment-timezone</code> library.</li>
<li><strong>Immutability.</strong><br />
Once a Temporal object is created, it cannot be changed. Time arithmetic or time zone conversions do not modify the underlying object. Instead, they generate a new Temporal object.</li>
<li><strong>1-based indexing.</strong><br />
A common source of bugs with the Date API (as well as with Moment) is that months are zero-indexed. This means that January is month <code>0</code>, rather than month <code>1</code> as we all understand in real life. Temporal fixes this by using 1-based indexing &mdash; January is month <code>1</code>.</li>
<li><strong>It’s built into the browser.</strong><br />
Since Temporal is an API in the browser itself, it adds nothing to your application’s bundle size.</li>
</ul>

<p>It’s also important to note that the Date API isn’t going away. While Temporal supersedes this API, it is not being removed or deprecated. Many applications would break if browsers suddenly removed the Date API. However, also keep in mind that Moment is now considered a legacy project in maintenance mode.</p>

<p>In the rest of the article, we’ll look at some “recipes” for migrating Moment-based code to the new Temporal API. Let’s start refactoring!</p>

<h2 id="creating-date-and-time-objects">Creating Date And Time Objects</h2>

<p>Before we can manipulate dates and times, we have to create objects representing them. To create a Moment object representing the current date and time, use the <code>moment</code> function.</p>

<pre><code class="language-javascript">const now = moment();
console.log(now); 
// Moment&lt;2026-02-18T21:26:29-05:00&gt;
</code></pre>  

<p>This object can now be formatted or manipulated as needed.</p>

<div class="break-out">
<pre><code class="language-javascript">// convert to UTC
// warning: This mutates the Moment object and puts it in UTC mode!
console.log(now.utc()); 
// Moment&lt;2026-02-19T02:26:29Z&gt;

// print a formatted string - note that it's using the UTC time now
console.log(now.format('MM/DD/YYYY hh:mm:ss a')); 
// 02/19/2026 02:27:07 am
</code></pre>
</div>

<p>The key thing to remember about Moment is that a Moment object always includes information about the time <em>and</em> the date. If you only need to work with time information, this is usually fine, but it can cause unexpected behavior in situations like Daylight Saving Time or leap years, where the date can have an effect on time calculations.</p>

<p>Temporal is more flexible. You can create an object representing the current date and time by creating a <code>Temporal.Instant</code> object. This represents a point in time defined by the time since “the epoch” (midnight UTC on January 1, 1970). Temporal can reference this instant in time with nanosecond-level precision.</p>

<pre><code class="language-javascript">const now = Temporal.Now.instant();

// see raw nanoseconds since the epoch
console.log(now.epochNanoseconds);
// 1771466342612000000n

// format for UTC
console.log(now.toString());
// 2026-02-19T01:55:27.844Z

// format for a particular time zone
console.log(now.toString({ timeZone: 'America/New&#95;York' }));
// 2026-02-18T20:56:57.905-05:00
</code></pre>

<p><code>Temporal.Instant</code> objects can also be created for a specific time and date by using the <code>from</code> static method.</p>

<div class="break-out">
<pre><code class="language-javascript">const myInstant = Temporal.Instant.from('2026-02-18T21:10:00-05:00');

// Format the instant in the local time zone. Note that this only controls
// the formatting - it does not mutate the object like `moment.utc` does.
console.log(myInstant.toString({ timeZone: 'America/New&#95;York' }));
// 2026-02-18T21:10:00-05:00
</code></pre>
</div>

<p>You can also create other types of Temporal objects, including:</p>

<ul>
<li><strong><code>Temporal.PlainDate</code></strong>: A date with no time information.</li>
<li><strong><code>Temporal.PlainTime</code></strong>: A time with no date information.</li>
<li><strong><code>Temporal.ZonedDateTime</code></strong>: A date and time in a specific time zone.</li>
</ul>

<p>Each of these has a <code>from</code> method that can be called with an object specifying the date and/or time, or a date string to parse.</p>

<pre><code class="language-javascript">// Just a date
const today = Temporal.PlainDate.from({
  year: 2026,
  month: 2, // note we're using 2 for February
  day: 18
});
console.log(today.toString());
// 2026-02-18

// Just a time
const lunchTime = Temporal.PlainTime.from({
  hour: 12
});
console.log(lunchTime.toString());
// 12:00:00 

// A date and time in the US Eastern time zone
const dueAt = Temporal.ZonedDateTime.from({
  timeZone: 'America/New&#95;York',
  year: 2026,
  month: 3,
  day: 1,
  hour: 12,
  minute: 0,
  second: 0
});
console.log(dueAt.toString());
// 2026-03-01T12:00:00-05:00[America/New&#95;York]
</code></pre>

<div class="partners__lead-place"></div>

<h2 id="parsing">Parsing</h2>

<p>We’ve covered programmatic creation of date and time information. Now let’s look at parsing. Parsing is one area where Moment is more flexible than the built-in Temporal API.</p>

<p>You can parse a date string by passing it to the <code>moment</code> function. With a single argument, Moment expects an ISO date string, but you can use alternative formats if you provide a second argument specifying the date format being used.</p>

<div class="break-out">
<pre><code class="language-javascript">const isoDate = moment('2026-02-21T09:00:00');
const formattedDate = moment('2/21/26 9:00:00', 'M/D/YY h:mm:ss');

console.log(isoDate);
// Moment&lt;2026-02-21T09:00:00-05:00&gt;

console.log(formattedDate);
// Moment&lt;2026-02-21T09:00:00-05:00&gt;
</code></pre>
</div>

<p>In older versions, Moment would make a best guess to parse any arbitrarily formatted date string. This could lead to unpredictable results. For example, is <code>02-03-2026</code> February 2 or March 3? For this reason, newer versions of Moment display a prominent deprecation warning if it’s called without an ISO formatted date string (unless the second argument with the desired format is also given).</p>

<p>Temporal will only parse a specifically formatted date string. The string must be compliant with the ISO 8601 format or its extension, RFC 9557. If a non-compliant date string is passed to a <code>from</code> method, Temporal will throw a <code>RangeError</code>.</p>

<div class="break-out">
<pre><code class="language-javascript">// Using an RFC 9557 date string
const myDate = Temporal.Instant.from('2026-02-21T09:00:00-05:00[America/New&#95;York]');
console.log(myDate.toString({ timeZone: 'America/New&#95;York' }));
// 2026-02-21T09:00:00-05:00

// Using an unknown date string
const otherDate = Temporal.Instant.from('2/21/26 9:00:00');
// RangeError: Temporal error: Invalid character while parsing year value.
</code></pre>
</div>

<p>The exact requirements of the date string depend on which kind of Temporal object you’re creating. In the above example, <code>Temporal.Instant</code> requires a full ISO 8601 or RFC 9557 date string specifying the date and time with a time zone offset, but you can also create <code>PlainDate</code> or <code>PlainTime</code> objects using just a subset of the date format.</p>

<pre><code class="language-javascript">const myDate = Temporal.PlainDate.from('2026-02-21');
console.log(myDate.toString());
// 2026-02-21

const myTime = Temporal.PlainTime.from('09:00:00');
console.log(myTime.toString());
// 09:00:00
</code></pre>

<p>Note that these strings must still comply with the expected format, or an error will be thrown.</p>

<div class="break-out">
<pre><code class="language-javascript">// Using a non-compliant time strings. These will all throw a RangeError.
Temporal.PlainTime.from('9:00');
Temporal.PlainTime.from('9:00:00 AM');
</code></pre>
</div>

<blockquote><strong>Pro tip: Handling non-ISO strings</strong><br /><br />Because Temporal prioritizes reliability, it won’t try to guess the format of a string like <code>02-01-2026</code>. If your data source uses such strings, you will need to do some string manipulation to rearrange the values into an ISO string like <code>2026-02-01</code> before attempting to use it with Temporal.</blockquote>

<h2 id="formatting">Formatting</h2>

<p>Once you have a Moment or Temporal object, you’ll probably want to convert it to a formatted string at some point.</p>

<p>This is an instance where Moment is a bit more terse. You call the object’s <code>format</code> method with a string of tokens that describe the desired date format.</p>

<pre><code class="language-javascript">const date = moment();

console.log(date.format('MM/DD/YYYY'));
// 02/22/2026

console.log(date.format('MMMM Do YYYY, h:mm:ss a'));
// February 22nd 2026, 8:18:30 pm
</code></pre>

<p>On the other hand, Temporal requires you to be a bit more verbose. Temporal objects, such as <code>Instant</code>, have a <code>toLocaleString</code> method that accepts various formatting options specified as properties of an object.</p>

<div class="break-out">
<pre><code class="language-javascript">const date = Temporal.Now.instant();

// with no arguments, we'll get the default format for the current locale
console.log(date.toLocaleString());
// 2/22/2026, 8:23:36 PM (assuming a locale of en-US)

// pass formatting options to generate a custom format string
console.log(date.toLocaleString('en-US', {
  month: 'long',
  day: 'numeric',
  year: 'numeric',
  hour: '2-digit',
  minute: '2-digit'
}));
// February 22, 2026 at 8:23 PM

// only pass the fields you want in the format string
console.log(date.toLocaleString('en-US', {
  month: 'short',
  day: 'numeric'
}));
// Feb 22
</code></pre>
</div>

<p><strong>Temporal date formatting actually uses the <code>Intl.DateTimeFormat</code> API</strong> (which is already readily available in modern browsers) under the hood. That means you can create a reusable <code>DateTimeFormat</code> object with your custom formatting options, then pass Temporal objects to its <code>format</code> method. Because of this, it doesn’t support custom date formats like Moment does. If you need something like <code>'Q1 2026'</code> or other specialized formatting, you may need some custom date formatting code or reach for a third-party library.</p>

<pre><code class="language-javascript">const formatter = new Intl.DateTimeFormat('en-US', {
  month: '2-digit',
  day: '2-digit',
  year: 'numeric'
});

const date = Temporal.Now.instant();
console.log(formatter.format(date));
// 02/22/2026
</code></pre>

<p>Moment’s formatting tokens are simpler to write, but they aren’t locale-friendly. The format strings “hard code” things like month/day order. The advantage of using a configuration object, as Temporal does, is that it will automatically adapt to any given locale and use the correct format.</p>

<pre><code class="language-javascript">const date = Temporal.Now.instant();

const formatOptions = {
  month: 'numeric',
  day: 'numeric',
  year: 'numeric'
};


console.log(date.toLocaleString('en-US', formatOptions));
// 2/22/2026

console.log(date.toLocaleString('en-GB', formatOptions));
// 22/02/2026
</code></pre>

<h2 id="date-calculations">Date calculations</h2>

<p>In many applications, you’ll need to end up performing some calculations on a date. You may want to add or subtract units of time (days, hours, seconds, etc.). For example, if you have the current date, you may want to show the user the date 1 week from now.</p>

<p>Moment objects have methods such as <code>add</code> and <code>subtract</code> that perform these operations. These functions take a value and a unit, for example: <code>add(7, 'days')</code>. One very important difference between Moment and Temporal, however, is that when performing these date calculations, the underlying object is modified and its original value is lost.</p>

<pre><code class="language-javascript">const now = moment();

console.log(now);
// Moment&lt;2026-02-24T20:08:36-05:00&gt;

const nextWeek = now.add(7, 'days');
console.log(nextWeek);
// Moment&lt;2026-03-03T20:08:36-05:00&gt;

// Gotcha - the original object was mutated
console.log(now);
// Moment&lt;2026-03-03T20:08:36-05:00&gt;
</code></pre>

<p>To avoid losing the original date, you can call <code>clone</code> on the Moment object to create a copy.</p>

<pre><code class="language-javascript">const now = moment();
const nextWeek = now.clone().add(7, 'days');

console.log(now);
// Moment&lt;2026-02-24T20:12:55-05:00&gt;

console.log(nextWeek);
// Moment&lt;2026-03-03T20:12:55-05:00&gt;
</code></pre>

<p>On the other hand, Temporal objects are <em>immutable</em>. Once you’ve created an object like an <code>Instant</code>, <code>PlainDate</code>, and so on, the value of that object will never change. Temporal objects also have <code>add</code> and <code>subtract</code> methods.</p>

<p>Temporal is a little picky about which time units can be added to which object types. For example, you can’t add days to an <code>Instant</code>:</p>

<div class="break-out">
<pre><code class="language-javascript">const now = Temporal.Now.instant();
const nextWeek = now.add({ days: 7 });
// RangeError: Temporal error: Largest unit cannot be a date unit
</code></pre>
</div>

<p>This is because <code>Instant</code> objects represent a specific point in time in UTC and are calendar-agnostic. Because the length of a day can change based on time zone rules such as Daylight Saving Time, this calculation isn’t available on an <code>Instant</code>. You <em>can</em>, however, perform this operation on other types of objects, such as a <code>PlainDateTime</code>:</p>

<pre><code class="language-javascript">const now = Temporal.Now.plainDateTimeISO();
console.log(now.toLocaleString());
// 2/24/2026, 8:23:59 PM

const nextWeek = now.add({ days: 7 });

// Note that the original PlainDateTime remains unchanged
console.log(now.toLocaleString());
// 2/24/2026, 8:23:59 PM

console.log(nextWeek.toLocaleString());
// 3/3/2026, 8:23:59 PM
</code></pre>

<p>You can also calculate how much time is between two Moment or Temporal objects.</p>

<p>With Moment’s <code>diff</code> function, you need to provide a unit for granularity, otherwise it will return the difference in milliseconds.</p>

<pre><code class="language-javascript">const date1 = moment('2026-02-21T09:00:00');
const date2 = moment('2026-02-22T10:30:00');

console.log(date2.diff(date1));
// 91800000

console.log(date2.diff(date1, 'days'));
// 1
</code></pre>

<p>To do this with a Temporal object, you can pass another Temporal object to its <code>until</code> or <code>since</code> methods. This returns a <code>Temporal.Duration</code> object containing information about the time difference. The <code>Duration</code> object has properties for each component of the difference, and also can generate an <a href="https://en.wikipedia.org/wiki/ISO_8601#Durations">ISO 8601</a> duration string representing the time difference.</p>

<div class="break-out">
<pre><code class="language-javascript">const date1 = Temporal.PlainDateTime.from('2026-02-21T09:00:00');
const date2 = Temporal.PlainDateTime.from('2026-02-22T10:30:00');

// largestUnit specifies the largest unit of time to represent
// in the duration calculation
const diff = date2.since(date1, { largestUnit: 'day' });

console.log(diff.days);
// 1

console.log(diff.hours);
// 1

console.log(diff.minutes);
// 30

console.log(diff.toString());
// P1DT1H30M
// (ISO 8601 duration string: 1 day, 1 hour, 30 minutes)
</code></pre>
</div>

<h2 id="comparing-dates-and-times">Comparing Dates And Times</h2>

<p>Moment and Temporal both let you compare dates and times to determine which comes before the other, but take different approaches with the API.</p>

<p>Moment provides methods such as <code>isBefore</code>, <code>isAfter</code>, and <code>isSame</code> to compare two Moment objects.</p>

<pre><code class="language-javascript">const date1 = moment('2026-02-21T09:00:00');
const date2 = moment('2026-02-22T10:30:00');

console.log(date1.isBefore(date2));
// true
</code></pre>

<p>Temporal uses a static <code>compare</code> method to perform a comparison between two objects of the same type. It returns <code>-1</code> if the first date comes before the second, <code>0</code> if they are equal, or <code>1</code> if the first date comes after the second. The following example shows how to compare two <code>PlainDate</code> objects. Both arguments to <code>Temporal.PlainDate.compare</code> must be <code>PlainDate</code> objects.</p>

<div class="break-out">
<pre><code class="language-javascript">const date1 = Temporal.PlainDate.from({ year: 2026, month: 2, day: 24 });
const date2 = Temporal.PlainDate.from({ year: 2026, month: 3, day: 24 });

// date1 comes before date2, so -1
console.log(Temporal.PlainDate.compare(date1, date2));

// Error if we try to compare two objects of different types
console.log(Temporal.PlainDate.compare(date1, Temporal.Now.instant()));
// TypeError: Temporal error: Invalid PlainDate fields provided.
</code></pre>
</div>

<p>In particular, this makes it easy to sort an array of Temporal objects chronologically.</p>

<pre><code class="language-javascript">// An array of Temporal.PlainDate objects
const dates = [ ... ];

// use Temporal.PlainDate.compare as the comparator function
dates.sort(Temporal.PlainDate.compare);
</code></pre>

<h2 id="time-zone-conversions">Time Zone Conversions</h2>

<p>The core Moment library doesn’t support time zone conversions. If you need this functionality, you also need to install the <code>moment-timezone</code> package. This package is not tree-shakable, and therefore can add significantly to your bundle size. Once you’ve installed <code>moment-timezone</code>, you can convert Moment objects to different time zones with the <code>tz</code> method. As with other Moment operations, this mutates the underlying object.</p>

<pre><code class="language-javascript">// Assuming US Eastern time
const now = moment();
console.log(now);
// Moment&lt;2026-02-28T20:08:20-05:00&gt;

// Convert to Pacific time.
// The original Eastern time is lost.
now.tz('America/Los&#95;Angeles');
console.log(now);
// Moment&lt;2026-02-28T17:08:20-08:00&gt;
</code></pre>

<p>Time zone functionality is built into the Temporal API when using a <code>Temporal.ZonedDateTime</code> object. These objects include a <code>withTimeZone</code> method that returns a new <code>ZonedDateTime</code> representing the same moment in time, but in the specified time zone.</p>

<pre><code class="language-javascript">// Again, assuming US Eastern time
const now = Temporal.Now.zonedDateTimeISO();
console.log(now.toLocaleString());
// 2/28/2026, 8:12:02 PM EST

// Convert to Pacific time
const nowPacific = now.withTimeZone('America/Los&#95;Angeles');
console.log(nowPacific.toLocaleString());
// 2/28/2026, 5:12:02 PM PST

// Original object remains unchanged
console.log(now.toLocaleString());
// 2/28/2026, 8:12:02 PM EST
</code></pre>

<p><strong>Note:</strong> <em>The formatted values returned by <code>toLocaleString</code> are, as the name implies, locale-dependent. The sample code was developed in the <code>en-US</code> locale, so the format is like this: <code>2/28/2026, 5:12:02 PM PST</code>. In another locale, this may be different. For example, in the <code>en-GB</code> locale, you would get something like <code>28/2/2026, 17:12:02 GMT-8</code>.</em></p>

<div class="partners__lead-place"></div>

<h2 id="a-real-world-refactoring">A Real-world Refactoring</h2>

<p>Suppose we’re building an app for scheduling events across time zones. Part of this app is a function, <code>getEventTimes</code>, which takes an ISO 8601 string representing the time and date of the event, a local time zone, and a target time zone. The function creates formatted time and date strings for the event in both time zones.</p>

<p>If the function is given an input string that’s not a valid time/date string, it will throw an error.</p>

<p>Here’s the original implementation, using Moment (also requiring use of the <code>moment-timezone</code> package).</p>

<div class="break-out">
<pre><code class="language-javascript">import moment from 'moment-timezone';

function getEventTimes(inputString, userTimeZone, targetTimeZone) {
  const timeFormat = 'MMM D, YYYY, h:mm:ss a z';

  // 1. Create the initial moment in the user's time zone
  const eventTime = moment.tz(
    inputString,
    moment.ISO&#95;8601, // Expect an ISO 8601 string
    true, // Strict parsing
    userTimeZone
  );
  
  // Throw an error if the inputString did not represent a valid date
  if (!eventTime.isValid()) {
    throw new Error('Invalid date/time input');
  }

  // 2. Calculate the target time
  // CRITICAL: We must clone, or 'eventTime' changes forever!
  const targetTime = eventTime.clone().tz(targetTimeZone);

  return {
    local: eventTime.format(timeFormat),
    target: targetTime.format(timeFormat),
  };
}

const schedule = getEventTimes(
  '2026-03-05T15:00-05:00',
  'America/New&#95;York',
  'Europe/London',
);

console.log(schedule.local);
// Mar 5, 2026, 3:00:00 pm EST

console.log(schedule.target); 
// Mar 5, 2026, 8:00:00 pm GMT
</code></pre>
</div>

<p>In this example, we’re using an expected date format of ISO 8601, which is helpfully built into Moment. We’re also using strict parsing, which means Moment won’t try to guess with a date string that doesn’t match the format. If a non-ISO date string is passed, it will result in an invalid date object, and we throw an error.</p>

<p>The Temporal implementation looks similar, but has a few key differences.</p>

<div class="break-out">
<pre><code class="language-javascript">function getEventTimes(inputString, userTimeZone, targetTimeZone) {
  // 1. Parse the input directly into an Instant, then create
  // a ZonedDateTime in the user's zone.
  const instant = Temporal.Instant.from(inputString);
  const eventTime = instant.toZonedDateTimeISO(userTimeZone);

  // 2. Convert to the target zone
  // This automatically returns a NEW object; 'eventTime' is safe.
  const targetTime = eventTime.withTimeZone(targetTimeZone);

  // 3. Format using Intl (built-in)
  const options = {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
    hour: 'numeric',
    minute: '2-digit',
    second: '2-digit',
    timeZoneName: 'short'
  };

  return {
    local: eventTime.toLocaleString(navigator.language, options),
    target: targetTime.toLocaleString(navigator.language, options)
  };
}

const schedule = getEventTimes(
  '2026-03-05T15:00-05:00',
  'America/New&#95;York',
  'Europe/London',
);

console.log(schedule.local);
// Mar 5, 2026, 3:00:00 PM EST

console.log(schedule.target);
// Mar 5, 2026, 8:00:00 PM GMT
</code></pre>
</div>

<p>With Moment, we have to explicitly specify a format string for the resulting date strings. Regardless of the user’s location or locale, the event times will always be formatted as <code>Mar 5, 2026, 3:00:00 pm EST</code>.</p>

<p>Also, we don’t have to explicitly throw an exception. If an invalid string is passed to <code>Temporal.Instant.from</code>, Temporal will throw the exception for us. One thing to note is that even with strict parsing, the Moment version is still more lenient. Temporal requires the time zone offset at the end of the string.</p>

<p>You should also note that since we’re using <code>navigator.language</code>, this code will only run in a browser environment, as <code>navigator</code> is not defined in a Node.js environment.</p>

<p>The Temporal implementation uses the browser’s current locale (<code>navigator.language</code>), so the user will automatically get event times formatted in their local time format. In the <code>en-US</code> locale, this is <code>Mar 5, 2026, 3:00:00 pm EST</code>. However, if the user is in London, for example, the event times will be formatted as <code>5 Mar 2026, 15:00:00 GMT-5</code>.</p>

<h2 id="summary">Summary</h2>

<table class="tablesaw break-out">
    <thead>
        <tr>
            <th>Action</th>
            <th>Moment.js</th>
      <th>Temporal</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Current time</td>
            <td><code>moment()</code></td>
      <td><code>Temporal.Now.zonedDateTimeISO()</code></td>
        </tr>
        <tr>
            <td>Parsing ISO</td>
            <td><code>moment(str)</code></td>
      <td><code>Temporal.Instant.from(str)</code></td>
        </tr>
        <tr>
            <td>Add time</td>
            <td><code>.add(7, 'days')</code> (mutates)</td>
      <td><code>.add({ days: 7 })</code> (new object)</td>
        </tr>
    <tr>
            <td>Difference</td>
            <td><code>.diff(other, 'hours')</code></td>
      <td><code>.since(other).hours</code></td>
        </tr>
    <tr>
            <td>Time zone</td>
            <td><code>.tz('Zone/Name')</code></td>
      <td><code>.withTimeZone('Zone/Name')</code></td>
        </tr>
    </tbody>
</table>

<p>At first glance, the difference may be slightly different (and in the case of Temporal, sometimes more verbose and more strict) syntax, but there are several key advantages to using Temporal over Moment.js:</p>

<ul>
<li>Being more explicit means <strong>fewer surprises and unintended bugs</strong>. Moment may appear to be more lenient, but it involves “guesswork,” which can sometimes result in incorrect dates. If you give Temporal something invalid, it throws an error. If the code runs, you know you’ve got a valid date.</li>
<li>Moment can add significant size to the application’s bundle, particularly if you’re using the <code>moment-timezone</code> package. Temporal adds nothing (once it’s shipped in your target browsers).</li>
<li><strong>Immutability</strong> gives you the confidence that you’ll never lose or overwrite data when performing date conversions and operations.</li>
<li><strong>Different representations of time</strong> (<code>Instant</code>, <code>PlainDateTime</code>, <code>ZonedDateTime</code>) depending on your requirements, where Moment is always a wrapper around a UTC timestamp.</li>
<li>Temporal uses the <strong><code>Intl</code> APIs for date formatting</strong>, which means you can have locale-aware formatting without having to explicitly specify tokens.</li>
</ul>

<h2 id="notes-on-the-polyfill">Notes On The Polyfill</h2>

<p>As mentioned earlier, there is a Temporal polyfill available, distributed as an npm package named <code>@js-temporal/polyfill</code>. If you want to use Temporal today, you’ll need this polyfill to support browsers like Safari that haven’t shipped the API yet. The bad news with this is that it will add to your bundle size. The good news is that it still adds significantly less than <code>moment</code> or <code>moment-timezone</code>. Here is a comparison of the bundle sizes as reported by Bundlephobia.com, a website that presents information on npm package sizes (click on each package name to see the Bundlephobia analysis):</p>

<table class="tablesaw break-out">
    <thead>
        <tr>
            <th>Package</th>
            <th>Minified</th>
      <th>Minified & gzipped</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><a href="https://bundlephobia.com/package/@js-temporal/polyfill"><code>@js-temporal/polyfill</code></a></td>
            <td>154.1 kB</td>
      <td>44.1 kB</td>
        </tr>
        <tr>
            <td><a href="https://bundlephobia.com/package/moment"><code>moment</code></a></td>
            <td>294.4 kB</td>
      <td>75.4 kB</td>
        </tr>
        <tr>
            <td><a href="https://bundlephobia.com/package/moment-timezone"><code>moment-timezone</code></a></td>
            <td>1 MB</td>
      <td>114.2 kB</td>
        </tr>
    </tbody>
</table>

<p>The polyfill also has historically had some performance issues around memory usage, and at the time of writing, it’s considered to be in an alpha state. Because of this, you may not want to use it in production until it reaches a more mature state.</p>

<p>The other good news is that hopefully the polyfill won’t be needed much longer (unless you need to support older browsers, of course). At the time of writing, Temporal has shipped in Chrome, Edge, and Firefox. It’s not quite ready in Safari yet, though it appears to be available with a runtime flag on the latest Technology Preview.</p>

<div class="signature">
  <img src="https://www.smashingmagazine.com/images/logo/logo--red.png" alt="Smashing Editorial" width="35" height="46" loading="lazy" decoding="async" />
  <span>(gg, yk)</span>
</div>


              </article>
            </body>
          </html>
        ]]></content:encoded></item><item><author>Sunil Sandhu</author><title>Building Dynamic Forms In React And Next.js</title><link>https://www.smashingmagazine.com/2026/03/building-dynamic-forms-react-next-js/</link><pubDate>Tue, 10 Mar 2026 13:00:00 +0000</pubDate><guid>https://www.smashingmagazine.com/2026/03/building-dynamic-forms-react-next-js/</guid><description>Some forms stay UI, while others quietly become rule engines. Here’s why these two different approaches exist and how to choose between them.</description><content:encoded><![CDATA[
          <html>
            <head>
              <meta charset="utf-8">
              <link rel="canonical" href="https://www.smashingmagazine.com/2026/03/building-dynamic-forms-react-next-js/" />
              <title>Building Dynamic Forms In React And Next.js</title>
            </head>
            <body>
              <article>
                <header>
                  <h1>Building Dynamic Forms In React And Next.js</h1>
                  
                    
                    <address>Sunil Sandhu</address>
                  
                  <time datetime="2026-03-10T13:00:00&#43;00:00" class="op-published">2026-03-10T13:00:00+00:00</time>
                  <time datetime="2026-03-10T13:00:00&#43;00:00" class="op-modified">2026-05-12T16:11:33+00:00</time>
                </header>
                <p>This article is sponsored by <b>SurveyJS</b></p>
                

<p>There’s a mental model most React developers share without ever discussing it out loud. That forms are <em>always</em> supposed to be components. This means a stack like:</p>

<ul>
<li><strong>React Hook Form</strong> for local state (minimal re-renders, ergonomic field registration, imperative interaction).</li>
<li><strong>Zod</strong> for validation (input correctness, boundary validation, type-safe parsing).</li>
<li><strong>React Query</strong> for backend: submission, retries, caching, server sync, and so on.</li>
</ul>

<p>And for the vast majority of forms &mdash; your login screens, your settings pages, your CRUD modals &mdash; this works really well. Each piece does its job, they compose cleanly, and you can move on to the parts of your application that actually differentiate your product.</p>

<p>But every once in a while, a form starts accumulating things like visibility rules that depend on earlier answers, or derived values that cascade through three fields. Maybe even entire pages that should be skipped or shown based on a running total.</p>

<p>You handle the first conditional with a <code>useWatch</code> and an inline branch, which is fine. Then another. <a href="https://zod.dev/api#superrefine">Then you’re reaching for <code>superRefine</code></a> to encode cross-field rules that your Zod schema can’t express in the normal way. Then, step navigation starts leaking business logic. At some point, you look at what you’ve built and realize that the form isn’t really UI anymore. It’s more of a decision process, and the component tree is just where you happened to store it.</p>

<p>This is where I think the mental model for forms in React breaks down, and it’s really nobody’s fault. The RHF + Zod stack is excellent at what it was designed for. <strong>The issue is that we tend to keep using it past the point where its abstractions match the problem</strong> because the alternative requires a different way of thinking about forms entirely.</p>

<p>This article is about that alternative. To show this, we’ll build the exact same multi-step form twice:</p>

<ol>
<li>With React Hook Form + Zod wired to React Query for submission,</li>
<li>With SurveyJS, which treats a form as data &mdash; a simple JSON schema &mdash; rather than a component tree.</li>
</ol>

<p>Same requirements, same conditional logic, same API call at the end. Then we’ll map exactly what moved and what stayed, and lay out a practical way to decide which model you should use, and when.</p>

<p><strong>The form we’re building:</strong></p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/building-dynamic-forms-react-next-js/1-dynamic-form.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="798"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/building-dynamic-forms-react-next-js/1-dynamic-form.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/building-dynamic-forms-react-next-js/1-dynamic-form.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/building-dynamic-forms-react-next-js/1-dynamic-form.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/building-dynamic-forms-react-next-js/1-dynamic-form.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/building-dynamic-forms-react-next-js/1-dynamic-form.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/building-dynamic-forms-react-next-js/1-dynamic-form.png"
			
			sizes="100vw"
			alt="Multi-step dynamic form"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/building-dynamic-forms-react-next-js/1-dynamic-form.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>This form will use a 4-step flow:</p>

<p><strong>Step 1: Details</strong></p>

<ul>
<li>First name (required),</li>
<li>Email (required, valid format).</li>
</ul>

<p><strong>Step 2: Order</strong></p>

<ul>
<li>Unit price,</li>
<li>Quantity,</li>
<li>Tax rate,</li>
<li>Derived:

<ul>
<li>Subtotal,</li>
<li>Tax,</li>
<li>Total.</li>
</ul></li>
</ul>

<p><strong>Step 3: Account &amp; Feedback</strong></p>

<ul>
<li>Do you have an account? (Yes/No)

<ul>
<li>If Yes → username + password, both required.</li>
<li>If No → email already collected in step 1.</li>
</ul></li>
<li>Satisfaction rating (1–5)

<ul>
<li>If ≥ 4 → ask “What did you like?”</li>
<li>If ≤ 2 → ask “What can we improve?”</li>
</ul></li>
</ul>

<p><strong>Step 4: Review</strong></p>

<ul>
<li>Only appears if <code>total &gt;= 100</code></li>
<li>Final submission.</li>
</ul>

<p>This is not extreme. But it’s enough to expose architectural differences.</p>

<h2 id="part-1-component-driven-react-hook-form-zod">Part 1: Component-Driven (React Hook Form + Zod)</h2>

<h3 id="installation">Installation</h3>

<pre><code class="language-bash">npm install react-hook-form zod @hookform/resolvers @tanstack/react-query
</code></pre>

<h3 id="zod-schema">Zod Schema</h3>

<p>Let’s start with the Zod schema, because that’s usually where the shape of the form gets established. For the first two steps &mdash; personal details and order inputs &mdash; everything is straightforward: required strings, numbers with minimums, and an enum. The interesting part starts when you try to express the conditional rules.</p>

<div class="break-out">
<pre><code class="language-typescript">import { z } from "zod";

export const formSchema = z.object({  
  firstName: z.string().min(1, "Required"),  
  email: z.string().email("Invalid email"),  
  price: z.number().min(0),  
  quantity: z.number().min(1),  
  taxRate: z.number(),  
  hasAccount: z.enum(["Yes", "No"]),  
  username: z.string().optional(),  
  password: z.string().optional(),  
  satisfaction: z.number().min(1).max(5),  
  positiveFeedback: z.string().optional(),  
  improvementFeedback: z.string().optional(),  
}).superRefine((data, ctx) =&gt; {  
  if (data.hasAccount === "Yes") {  
    if (!data.username) {  
      ctx.addIssue({ code: "custom", path: ["username"], message: "Required" });  
    }  
    if (!data.password || data.password.length &lt; 6) {  
      ctx.addIssue({ code: "custom", path: ["password"], message: "Min 6 characters" });  
    }  
  }

  if (data.satisfaction &gt;= 4 && !data.positiveFeedback) {  
    ctx.addIssue({ code: "custom", path: ["positiveFeedback"], message: "Please share what you liked" });  
  }

  if (data.satisfaction &lt;= 2 && !data.improvementFeedback) {  
    ctx.addIssue({ code: "custom", path: ["improvementFeedback"], message: "Please tell us what to improve" });  
  }  
});

export type FormData = z.infer&lt;typeof formSchema&gt;;
</code></pre>
</div>

<p>Notice that <code>username</code> and <code>password</code> are typed as <code>optional()</code> even though they’re conditionally required because Zod’s type-level schema describes the <em>shape</em> of the object, not the rules governing when fields matter.</p>

<p>The conditional requirement has to live inside <code>superRefine</code>, which runs after the shape is validated and has access to the full object. That separation is not a flaw; it’s just what the tool is designed for: <code>superRefine</code> is where cross-field logic goes when it can’t be expressed in the schema structure itself.</p>

<p>What’s also notable here is what this schema <em>doesn’t</em> express. It has no concept of pages, no concept of which fields are visible at which point, and no concept of navigation. All of that will live somewhere else.</p>

<h3 id="form-component">Form Component</h3>

<div class="break-out">
<pre><code class="language-typescript">import { useForm, useWatch } from "react-hook-form";  
import { zodResolver } from "@hookform/resolvers/zod";  
import { useMutation } from "@tanstack/react-query";  
import { useState, useMemo } from "react";  
import { formSchema, type FormData } from "./schema";

const STEPS = ["details", "order", "account", "review"];

type OrderPayload = FormData & { subtotal: number; tax: number; total: number };

export function RHFMultiStepForm() {  
  const [step, setStep] = useState(0);

  const mutation = useMutation({
    mutationFn: async (payload: OrderPayload) =&gt; {
      const res = await fetch("/api/orders", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      });
      if (!res.ok) throw new Error("Failed to submit");
      return res.json();
    },
  });

  const {  
    register,  
    control,  
    handleSubmit,  
    formState: { errors },  
  } = useForm&lt;FormData&gt;({  
    resolver: zodResolver(formSchema),  
    defaultValues: {  
      price: 0,  
      quantity: 1,  
      taxRate: 0.1,  
      satisfaction: 3,  
      hasAccount: "No",  
    },  
  });  

  const price = useWatch({ control, name: "price" });  
  const quantity = useWatch({ control, name: "quantity" });  
  const taxRate = useWatch({ control, name: "taxRate" });  
  const hasAccount = useWatch({ control, name: "hasAccount" });  
  const satisfaction = useWatch({ control, name: "satisfaction" });  

  const subtotal = useMemo(() =&gt; (price ?? 0) &#42; (quantity ?? 1), [price, quantity]);  
  const tax = useMemo(() =&gt; subtotal &#42; (taxRate ?? 0), [subtotal, taxRate]);  
  const total = useMemo(() =&gt; subtotal + tax, [subtotal, tax]);  

  const onSubmit = (data: FormData) =&gt; mutation.mutate({ ...data, subtotal, tax, total });  

  const showSubmit = (step === 2 && total &lt; 100) || (step === 3 && total &gt;= 100)

  return (  
    &lt;form onSubmit={handleSubmit(onSubmit)}&gt;  
      {step === 0 && (  
        &lt;&gt;  
          &lt;input {...register("firstName")} placeholder="First Name" /&gt;  
          &lt;input {...register("email")} placeholder="Email" /&gt;  
        &lt;/&gt;  
      )}

      {step === 1 && (  
        &lt;&gt;  
          &lt;input type="number" {...register("price", { valueAsNumber: true })} /&gt;  
          &lt;input type="number" {...register("quantity", { valueAsNumber: true })} /&gt;  
          &lt;select {...register("taxRate", { valueAsNumber: true })}&gt;  
            &lt;option value="0.05"&gt;5%&lt;/option&gt;  
            &lt;option value="0.1"&gt;10%&lt;/option&gt;  
            &lt;option value="0.15"&gt;15%&lt;/option&gt;  
          &lt;/select&gt;

          &lt;div&gt;Subtotal: {subtotal}&lt;/div&gt;  
          &lt;div&gt;Tax: {tax}&lt;/div&gt;  
          &lt;div&gt;Total: {total}&lt;/div&gt;  
        &lt;/&gt;  
      )}

      {step === 2 && (  
        &lt;&gt;  
          &lt;select {...register("hasAccount")}&gt;  
            &lt;option value="Yes"&gt;Yes&lt;/option&gt;  
            &lt;option value="No"&gt;No&lt;/option&gt;  
          &lt;/select&gt;

          {hasAccount === "Yes" && (  
            &lt;&gt;  
              &lt;input {...register("username")} placeholder="Username" /&gt;  
              &lt;input {...register("password")} placeholder="Password" /&gt;  
            &lt;/&gt;  
          )}

          &lt;input type="number" {...register("satisfaction", { valueAsNumber: true })} /&gt;

          {satisfaction &gt;= 4 && (  
            &lt;textarea {...register("positiveFeedback")} /&gt;  
          )}

          {satisfaction &lt;= 2 && (  
            &lt;textarea {...register("improvementFeedback")} /&gt;  
          )}  
        &lt;/&gt;  
      )}

      {step === 3 && total &gt;= 100 && &lt;div&gt;Review and submit&lt;/div&gt;}

      &lt;div&gt;  
        {step &gt; 0 && &lt;button type="button" onClick={() =&gt; setStep(step - 1)}&gt;Back&lt;/button&gt;}  
        {showSubmit ? (  
          &lt;button type="submit" disabled={mutation.isPending}&gt;  
            {mutation.isPending ? "Submitting…" : "Submit"}  
          &lt;/button&gt;  
        ) : step &lt; STEPS.length - 1 ? (  
          &lt;button type="button" onClick={() =&gt; setStep(step + 1)}&gt;Next&lt;/button&gt;  
        ) : null}  
      &lt;/div&gt;  
      {mutation.isError && &lt;div&gt;Error: {mutation.error.message}&lt;/div&gt;}  
    &lt;/form&gt;  
  );  
}
</code></pre>
</div>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="gbwwmNO"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [SurveyJS-03-RHF [forked]](https://codepen.io/smashingmag/pen/gbwwmNO) by <a href="https://codepen.io/sixthextinction">sixthextinction</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/gbwwmNO">SurveyJS-03-RHF [forked]</a> by <a href="https://codepen.io/sixthextinction">sixthextinction</a>.</figcaption>
</figure>

<p>There’s quite a lot happening here, and it’s worth slowing down to notice where things ended up.</p>

<ul>
<li>The derived values &mdash; <code>subtotal</code>, <code>tax</code>, <code>total</code> &mdash; are computed in the component via <code>useWatch</code> and <code>useMemo</code> because they depend on live field values and there’s no other natural place for them.</li>
<li>The visibility rules for <code>username</code>, <code>password</code>, <code>positiveFeedback</code>, and <code>improvementFeedback</code> live in JSX as inline conditionals.</li>
<li>The step-skipping logic &mdash; the review page only appearing when <code>total &gt;= 100</code> &mdash; is embedded into the <code>showSubmit</code> variable and the render condition on step 3.</li>
<li>Navigation itself is just a <code>useState</code> counter that we’re manually incrementing.</li>
<li>React Query handles retries, caching, and invalidation. The form just calls <code>mutation.mutate</code> with validated data.</li>
</ul>

<p>None of this is <em>wrong,</em> per se. This is still idiomatic React, and the component is quite performant thanks to how RHF isolates re-renders.</p>

<p>But if you were to hand this to someone who hadn’t written it and ask them to explain <em>under what conditions the review page appears</em>, they’d have to trace through <code>showSubmit</code>, the step 3 render condition, and the nav button logic &mdash; three separate places &mdash; to reconstruct a rule that could have been stated in one line.</p>

<p><strong>The form works, yes, but the behavior isn’t really inspectable as a system.</strong> It has to be executed mentally.</p>

<p>More importantly, changing it requires engineering involvement. Even a small tweak, like adjusting when the review step shows up, means editing the component, updating validation, opening a pull request, waiting for review, and deploying again.</p>

<h2 id="part-2-schema-driven-surveyjs">Part 2: Schema-Driven (SurveyJS)</h2>

<p>Now let’s build the same flow using a schema.</p>

<h3 id="installation-1">Installation</h3>

<pre><code class="language-bash">npm install survey-core survey-react-ui @tanstack/react-query
</code></pre>

<ul>
<li><code>survey-core</code><br />
The MIT-licensed platform-independent runtime engine that powers SurveyJS’s form rendering &mdash; the part we care about here. It takes a JSON schema, builds an internal model from it, and handles everything that would otherwise live in your React component: evaluating visibility expressions, computing derived values, managing page state, tracking validation, and deciding what “complete” means given which pages were actually shown.</li>
<li><code>survey-react-ui</code><br />
The UI / rendering layer that connects that model to React. It’s essentially a <code>&lt;Survey model={model} /&gt;</code> component that re-renders whenever the engine’s state changes. SurveyJS UI libraries are also available for <a href="https://www.npmjs.com/package/survey-angular">Angular</a>, <a href="https://www.npmjs.com/package/survey-vue3-ui">Vue3</a>, and many other frameworks.</li>
</ul>

<p>Together, they give you a fully functional, multi-page form runtime without writing a single line of control flow.</p>

<p>The schema format itself is, as said before, just a JSON &mdash; no DSL or anything proprietary. You can inline it, import it from a file, fetch it from an API, or store it in a database column and hydrate it at runtime.</p>

<h3 id="the-same-form-as-data">The Same Form, As Data</h3>

<p>Here’s the same form, this time expressed as a JSON object. The schema defines everything: structure, validation, visibility rules, derived calculations, page navigation &mdash; and hands it to a <code>Model</code> that evaluates it at runtime. Here’s what that looks like in full:</p>

<div class="break-out">
<pre><code class="language-javascript">export const surveySchema = {  
  title: "Order Flow",  
  showProgressBar: "top",  
  pages: [  
    {  
      name: "details",  
      elements: [  
        { type: "text", name: "firstName", isRequired: true },  
        { type: "text", name: "email", inputType: "email", isRequired: true, validators: [{ type: "email", text: "Invalid email" }] }  
      ]  
    },  
    {  
      name: "order",  
      elements: [  
        { type: "text", name: "price", inputType: "number", defaultValue: 0 },  
        { type: "text", name: "quantity", inputType: "number", defaultValue: 1 },  
        {  
          type: "dropdown",  
          name: "taxRate",  
          defaultValue: 0.1,  
          choices: [  
            { value: 0.05, text: "5%" },  
            { value: 0.1, text: "10%" },  
            { value: 0.15, text: "15%" }  
          ]  
        },  
        {  
          type: "expression",  
          name: "subtotal",  
          expression: "{price} * {quantity}"  
        },  
        {  
          type: "expression",  
          name: "tax",  
          expression: "{subtotal} * {taxRate}"  
        },  
        {  
          type: "expression",  
          name: "total",  
          expression: "{subtotal} + {tax}"  
        }  
      ]  
    },  
    {  
      name: "account",  
      elements: [  
        {  
          type: "radiogroup",  
          name: "hasAccount",  
          choices: ["Yes", "No"]  
        },  
        {  
          type: "text",  
          name: "username",  
          visibleIf: "{hasAccount} = 'Yes'",  
          isRequired: true  
        },  
        {  
          type: "text",  
          name: "password",  
          inputType: "password",  
          visibleIf: "{hasAccount} = 'Yes'",  
          isRequired: true,  
          validators: [{ type: "text", minLength: 6, text: "Min 6 characters" }]  
        },  
        {  
          type: "rating",  
          name: "satisfaction",  
          rateMin: 1,  
          rateMax: 5  
        },  
        {  
          type: "comment",  
          name: "positiveFeedback",  
          visibleIf: "{satisfaction} &gt;= 4"  
        },  
        {  
          type: "comment",  
          name: "improvementFeedback",  
          visibleIf: "{satisfaction} &lt;= 2"  
        }  
      ]  
    },  
    {  
      name: "review",  
      visibleIf: "{total} &gt;= 100",  
      elements: []  
    }  
  ]  
};
</code></pre>
</div>

<p>Compare this to the RHF version for a moment.</p>

<ul>
<li>The <code>superRefine</code> block that conditionally required <code>username</code> and <code>password</code> is gone. <code>visibleIf: &quot;{hasAccount} = 'Yes'&quot;</code> combined with <code>isRequired: true</code> handles both concerns together, on the field itself, where you&rsquo;d expect to find them.</li>
<li>The <code>useWatch</code> + <code>useMemo</code> chain that computed <code>subtotal</code>, <code>tax</code>, and <code>total</code> is replaced by three <code>expression</code> fields that reference each other by name.</li>
<li>The review page condition, which in the RHF version was reconstructable only by tracing through <code>showSubmit</code>, the step 3 render branch.</li>
<li>And finally, the nav button logic is a single <code>visibleIf</code> property on the page object.</li>
</ul>

<p>The same logic is there. It’s just that the schema gives it a place to live where it’s visible in isolation, rather than spread across the component.</p>

<p>Also, note that the schema uses <code>type: 'expression'</code> for subtotal, tax, and total. <a href="https://surveyjs.io/form-library/documentation/api-reference/expression-model">Expression</a> is read-only and used mainly to display calculated values. SurveyJS also supports <code>type: 'html'</code> for static content, but for calculated values, <code>expression</code> is the right choice.</p>

<p>Now for the React side.</p>

<h3 id="rendering-and-submission">Rendering And Submission</h3>

<p>Very simple. Wire <code>onComplete</code> to your API the same way &mdash; via <code>useMutation</code> or plain <code>fetch</code>:</p>

<div class="break-out">
<pre><code class="language-javascript">import { useState, useEffect, useRef } from "react";  
import { useMutation } from "@tanstack/react-query";  
import { Model } from "survey-core";  
import { Survey } from "survey-react-ui";  
import "survey-core/survey-core.css";

export function SurveyForm() {  
  const [model] = useState(() =&gt; new Model(surveySchema));

  const mutation = useMutation({
    mutationFn: async (data) =&gt; {
      const res = await fetch("/api/orders", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      });
      if (!res.ok) throw new Error("Failed to submit");
      return res.json();
    },
  });

  const mutationRef = useRef(mutation);
  mutationRef.current = mutation;
  useEffect(() =&gt; {  
    const handler = (sender) =&gt; mutationRef.current.mutate(sender.data);  
    model.onComplete.add(handler);  
    return () =&gt; model.onComplete.remove(handler);  
  }, [model]); // ref avoids re-registering handler every render (mutation object identity changes)

  return (
    &lt;&gt;
      &lt;Survey model={model} /&gt;  
      {mutation.isError && &lt;div&gt;Error: {mutation.error.message}&lt;/div&gt;}
    &lt;/&gt;
  );
}
</code></pre>
</div>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="emddWNV"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [SurveyJS-03-SurveyJS [forked]](https://codepen.io/smashingmag/pen/emddWNV) by <a href="https://codepen.io/sixthextinction">sixthextinction</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/emddWNV">SurveyJS-03-SurveyJS [forked]</a> by <a href="https://codepen.io/sixthextinction">sixthextinction</a>.</figcaption>
</figure>

<ul>
<li><code>onComplete</code> fires when the user reaches the end of the last <em>visible</em> page. So if <code>total</code> never crosses 100 and the review page is skipped, it still fires correctly because SurveyJS evaluates visibility before deciding what “last page” means.</li>
<li>Then, <code>sender.data</code> contains all answers along with the calculated values (<code>subtotal</code>, <code>tax</code>, <code>total</code>) as first-class fields, so the API payload is identical to what the RHF version assembled manually in <code>onSubmit</code>.</li>
<li>The <code>mutationRef</code> pattern is the same one you’d reach for anywhere you need a stable event handler over a value that changes on every render &mdash; nothing SurveyJS-specific about it.</li>
</ul>

<p>The React component no longer contains any business logic at all. There’s no <code>useWatch</code>, no conditional JSX, no step counter, no <code>useMemo</code> chain, no <code>superRefine</code>. React is doing what it’s actually good at: rendering a component and wiring it to an API call.</p>

<h2 id="what-moved-out-of-react">What Moved Out Of React?</h2>

<table class="tablesaw break-out">
    <thead>
        <tr>
            <th>Concern</th>
            <th>RHF Stack</th>
      <th>SurveyJS</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Visibility</td>
            <td>JSX branches</td>
      <td><code>visibleIf</code></td>
        </tr>
        <tr>
            <td>Derived values</td>
            <td><code>useWatch</code> / <code>useMemo</code></td>
      <td><code>expression</code></td>
        </tr>
        <tr>
            <td>Cross-field rules</td>
            <td><code>superRefine</code></td>
      <td>Schema conditions</td>
        </tr>
    <tr>
            <td>Navigation</td>
            <td><code>step</code> state</td>
      <td>Page <code>visibleIf</code></td>
        </tr>
     <tr>
            <td>Rule location</td>
            <td>Distributed across files</td>
      <td>Centralized in the schema</td>
        </tr>
    </tbody>
</table>

<p>What stays in React is layout, styling, submission wiring, and app integration, which is to say, <strong>the things React is actually designed for</strong>.</p>

<p>Everything else moved into the schema, and because the schema is just a JSON object, it can be stored in a database, versioned independently of your application code, or edited through internal tooling without requiring a deploy.</p>

<p>A product manager who needs to change the threshold that triggers the review page can do that without touching the component. That’s a meaningful operational difference for teams where form behavior evolves frequently and isn’t always driven by engineers.</p>

<h2 id="when-to-use-each-approach">When To Use Each Approach?</h2>

<p>Here’s a good rule of thumb that works for me: <strong>imagine deleting the form entirely</strong>. What would you lose?</p>

<ul>
<li>If it’s screens, you want component-driven forms.</li>
<li>If it’s business logic, like thresholds, branching rules, and conditional requirements that encode real decisions, you want a schema engine.</li>
</ul>

<p>Similarly, if the changes coming your way are mostly about labels, fields, and layout, RHF will serve you fine. If they’re about conditions, outcomes, and rules that your ops or legal team might need to adjust on a Tuesday afternoon without filing a ticket, the schema model with SurveyJS is the more honest fit.</p>

<p><strong>These two approaches are not really in competition with each other.</strong> They address different classes of problems, and the mistake worth avoiding is mismatching the abstraction to the weight of the logic &mdash; treating a rule system like a component because that’s the familiar tool, or reaching for a policy engine because a form grew to three steps and acquired a conditional field.</p>

<p>The form we built here sits near the boundary deliberately, complex enough to expose the difference but not so extreme that the comparison feels rigged. Most real forms that have gotten unwieldy in your codebase probably sit near that same boundary, and the question is usually just whether anyone has named what they actually are.</p>

<p><strong>Use React Hook Form + Zod when:</strong></p>

<ul>
<li>Forms are CRUD-oriented;</li>
<li>Logic is shallow and UI-driven;</li>
<li>Engineers own all behavior;</li>
<li>Backend remains the source of truth.</li>
</ul>

<p><strong>Use SurveyJS when:</strong></p>

<ul>
<li>Forms encode business decisions;</li>
<li>Rules evolve independently of UI;</li>
<li>Logic must be visible, auditable, or versioned;</li>
<li>Non-engineers influence behavior;</li>
<li>The same form must run across multiple frontends.</li>
</ul>

<div class="signature">
  <img src="https://www.smashingmagazine.com/images/logo/logo--red.png" alt="Smashing Editorial" width="35" height="46" loading="lazy" decoding="async" />
  <span>(yk)</span>
</div>


              </article>
            </body>
          </html>
        ]]></content:encoded></item><item><author>Blake Lundquist</author><title>CSS &lt;code>@scope&lt;/code>: An Alternative To Naming Conventions And Heavy Abstractions</title><link>https://www.smashingmagazine.com/2026/02/css-scope-alternative-naming-conventions/</link><pubDate>Thu, 05 Feb 2026 08:00:00 +0000</pubDate><guid>https://www.smashingmagazine.com/2026/02/css-scope-alternative-naming-conventions/</guid><description>Prescriptive class name conventions are no longer enough to keep CSS maintainable in a world of increasingly complex interfaces. Can the new &lt;code>@scope&lt;/code> rule finally give developers the confidence to write CSS that can keep up with modern front ends?</description><content:encoded><![CDATA[
          <html>
            <head>
              <meta charset="utf-8">
              <link rel="canonical" href="https://www.smashingmagazine.com/2026/02/css-scope-alternative-naming-conventions/" />
              <title>CSS &lt;code&gt;@scope&lt;/code&gt;: An Alternative To Naming Conventions And Heavy Abstractions</title>
            </head>
            <body>
              <article>
                <header>
                  <h1>CSS &lt;code&gt;@scope&lt;/code&gt;: An Alternative To Naming Conventions And Heavy Abstractions</h1>
                  
                    
                    <address>Blake Lundquist</address>
                  
                  <time datetime="2026-02-05T08:00:00&#43;00:00" class="op-published">2026-02-05T08:00:00+00:00</time>
                  <time datetime="2026-02-05T08:00:00&#43;00:00" class="op-modified">2026-05-12T16:11:33+00:00</time>
                </header>
                
                

<p>When learning the principles of basic CSS, one is taught to write modular, reusable, and descriptive styles to ensure maintainability. But when developers become involved with real-world applications, it often feels impossible to add UI features without styles leaking into unintended areas.</p>

<p>This issue often snowballs into a self-fulfilling loop; styles that are theoretically scoped to one element or class start showing up where they don’t belong. This forces the developer to create even more specific selectors to override the leaked styles, which then accidentally override global styles, and so on.</p>

<p>Rigid class name conventions, such as <a href="https://getbem.com/introduction/">BEM</a>, are one theoretical solution to this issue. The <strong>BEM (Block, Element, Modifier) methodology</strong> is a <a href="https://www.smashingmagazine.com/2012/04/a-new-front-end-methodology-bem/">systematic way of naming CSS classes</a> to ensure reusability and structure within CSS files. Naming conventions like this can <a href="https://www.smashingmagazine.com/2018/06/bem-for-beginners/">reduce cognitive load by leveraging domain language to describe elements and their state</a>, and if implemented correctly, <a href="https://www.smashingmagazine.com/2025/06/css-cascade-layers-bem-utility-classes-specificity-control/">can make styles for large applications easier to maintain</a>.</p>

<p>In the real world, however, it doesn’t always work out like that. Priorities can change, and with change, implementation becomes inconsistent. Small changes to the HTML structure can require many CSS class name revisions. With highly interactive front-end applications, class names following the BEM pattern can become long and unwieldy (e.g., <code>app-user-overview__status--is-authenticating</code>), and not fully adhering to the naming rules breaks the system’s structure, thereby negating its benefits.</p>

<p>Given these challenges, it’s no wonder that developers have turned to frameworks, Tailwind being <a href="https://2024.stateofcss.com/en-US/tools/">the most popular CSS framework</a>. Rather than trying to fight what seems like an unwinnable specificity war between styles, it is easier to give up on the <a href="https://css-tricks.com/the-c-in-css-the-cascade/">CSS Cascade</a> and use tools that guarantee complete isolation.</p>

<h2 id="developers-lean-more-on-utilities">Developers Lean More On Utilities</h2>

<p>How do we know that some developers are keen on avoiding cascaded styles? It’s the rise of “modern” front-end tooling &mdash; like <a href="https://www.smashingmagazine.com/2016/04/finally-css-javascript-meet-cssx/">CSS-in-JS frameworks</a> &mdash; designed specifically for that purpose. Working with isolated styles that are tightly scoped to specific components can seem like a breath of fresh air. It removes the need to name things &mdash; <a href="https://24ways.org/2014/naming-things/">still one of the most hated and time-consuming front-end tasks</a> &mdash; and allows developers to be productive without fully understanding or leveraging the benefits of CSS inheritance.</p>

<p>But ditching the CSS Cascade comes with its own problems. For instance, composing styles in JavaScript requires heavy build configurations and often leads to styles awkwardly intermingling with component markup or HTML. Instead of carefully considered naming conventions, we allow build tools to autogenerate selectors and identifiers for us (e.g., <code>.jsx-3130221066</code>), requiring developers to keep up with yet another pseudo-language in and of itself. (As if the cognitive load of understanding what all your component’s <code>useEffect</code>s do weren’t already enough!)</p>

<p>Further abstracting the job of naming classes to tooling means that basic debugging is often constrained to specific application versions compiled for development, rather than leveraging native browser features that support live debugging, such as Developer Tools.</p>

<blockquote class="pull-quote">
  <p>
    <a class="pull-quote__link" aria-label="Share on Twitter" href="https://twitter.com/share?text=%0aIt%e2%80%99s%20almost%20like%20we%20need%20to%20develop%20tools%20to%20debug%20the%20tools%20we%e2%80%99re%20using%20to%20abstract%20what%20the%20web%20already%20provides%20%e2%80%94%20all%20for%20the%20sake%20of%20running%20away%20from%20the%20%e2%80%9cpain%e2%80%9d%20of%20writing%20standard%20CSS.%0a&url=https://smashingmagazine.com%2f2026%2f02%2fcss-scope-alternative-naming-conventions%2f">
      
It’s almost like we need to develop tools to debug the tools we’re using to abstract what the web already provides — all for the sake of running away from the “pain” of writing standard CSS.

    </a>
  </p>
  <div class="pull-quote__quotation">
    <div class="pull-quote__bg">
      <span class="pull-quote__symbol">“</span></div>
  </div>
</blockquote>

<p>Luckily, modern CSS features not only make writing standard CSS more flexible but also give developers like us a great deal more power to manage the cascade and make it work for us. <a href="https://www.smashingmagazine.com/2022/01/introduction-css-cascade-layers/">CSS Cascade Layers</a> are a great example, but there’s another feature that gets a surprising lack of attention &mdash; although that is changing now that it has recently become <strong>Baseline compatible</strong>.</p>

<h2 id="the-css-scope-at-rule">The CSS <code>@scope</code> At-Rule</h2>

<p>I consider the <strong>CSS <code>@scope</code> at-rule</strong> to be a potential cure for the sort of style-leak-induced anxiety we’ve covered, one that does not force us to compromise native web advantages for abstractions and extra build tooling.</p>

<blockquote>“The <code>@scope</code> CSS at-rule enables you to select elements in specific DOM subtrees, targeting elements precisely without writing overly-specific selectors that are hard to override, and without coupling your selectors too tightly to the DOM structure.”<br /><br />&mdash; <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@scope">MDN</a></blockquote>

<p>In other words, we can work with isolated styles in specific instances <strong>without sacrificing inheritance, cascading, or even the basic separation of concerns</strong> that has been a long-running guiding principle of front-end development.</p>

<p>Plus, it has <a href="https://caniuse.com/css-cascade-scope">excellent browser coverage</a>. In fact, <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/146">Firefox 146</a> added support for <code>@scope</code> in December, making it <a href="https://developer.mozilla.org/en-US/docs/Glossary/Baseline/Compatibility">Baseline compatible</a> for the first time. Here is a simple comparison between a button using the BEM pattern versus the <code>@scope</code> rule:</p>

<pre><code class="language-html">&lt;!-- BEM --&gt; 
&lt;button class="button button--primary"&gt;
  &lt;span class="button&#95;&#95;text"&gt;Click me&lt;/span&gt;
  &lt;span class="button&#95;&#95;icon"&gt;→&lt;/span&gt;
&lt;/button&gt;

&lt;style&gt;
  .button .button&#95;&#95;text { /&#42; button text styles &#42;/ }
  .button .button&#95;&#95;icon { /&#42; button icon styles &#42;/ }
  .button--primary { primary button styles &#42;/ }
&lt;/style&gt;
</code></pre>

<pre><code class="language-html">&lt;!-- @scope --&gt; 
&lt;button class="primary-button"&gt;
  &lt;span&gt;Click me&lt;/span&gt;
  &lt;span&gt;→&lt;/span&gt;
&lt;/button&gt;

&lt;style&gt;
  @scope (.primary-button) {
    span:first-child { /&#42; button text styles &#42;/ }
    span:last-child { /&#42; button icon styles &#42;/ }
  }
&lt;/style&gt;
</code></pre>

<p>The <code>@scope</code> rule allows for <strong>precision with less complexity</strong>. The developer no longer needs to create boundaries using class names, which, in turn, allows them to write selectors based on native HTML elements, thereby eliminating the need for prescriptive CSS class name patterns. By simply removing the need for class name management, <code>@scope</code> can alleviate the fear associated with CSS in large projects.</p>

<h2 id="basic-usage">Basic Usage</h2>

<p>To get started, add the <code>@scope</code> rule to your CSS and insert a root selector to which styles will be scoped:</p>

<pre><code class="language-css">@scope (&lt;selector&gt;) {
  /&#42; Styles scoped to the &lt;selector&gt; &#42;/
}
</code></pre>

<p>So, for example, if we were to scope styles to a <code>&lt;nav&gt;</code> element, it may look something like this:</p>

<div class="break-out">
<pre><code class="language-css">@scope (nav) {
  a { /&#42; Link styles within nav scope &#42;/ }

  a:active { /&#42; Active link styles &#42;/ }

  a:active::before { /&#42; Active link with pseudo-element for extra styling &#42;/ }

  @media (max-width: 768px) {
    a { /&#42; Responsive adjustments &#42;/ }
  }
}
</code></pre>
</div>

<p>This, on its own, is not a groundbreaking feature. However, a second argument can be added to the scope to create a <strong>lower boundary</strong>, effectively defining the scope’s start and end points.</p>

<div class="break-out">
<pre><code class="language-css">/&#42; Any `a` element inside `ul` will not have the styles applied &#42;/
@scope (nav) to (ul) {
  a {
    font-size: 14px;
  }
}
</code></pre>
</div>

<p>This practice is called <strong>donut scoping</strong>, and <a href="https://css-tricks.com/solved-by-css-donuts-scopes/">there are several approaches</a> one could use, including a series of similar, highly specific selectors coupled tightly to the DOM structure, a <code>:not</code> pseudo-selector, or assigning specific class names to <code>&lt;a&gt;</code> elements within the <code>&lt;nav&gt;</code> to handle the differing CSS.</p>

<p>Regardless of those other approaches, the <code>@scope</code> method is much more concise. More importantly, it prevents the risk of broken styles if classnames change or are misused or if the HTML structure were to be modified. Now that <code>@scope</code> is Baseline compatible, we no longer need workarounds!</p>

<p>We can take this idea further with multiple end boundaries to create a “style figure eight”:</p>

<div class="break-out">
<pre><code class="language-css">/&#42; Any &lt;a&gt; or &lt;p&gt; element inside &lt;aside&gt; or &lt;nav&gt; will not have the styles applied &#42;/
@scope (main) to (aside, nav) {
  a {
    font-size: 14px;
  }
  p {
    line-height: 16px;
    color: darkgrey;
  }
}
</code></pre>
</div>

<p>Compare that to a version handled without the <code>@scope</code> rule, where the developer has to “reset” styles to their defaults:</p>

<div class="break-out">
<pre><code class="language-css">main a {
  font-size: 14px;
}

main p {
  line-height: 16px;
  color: darkgrey;
}

main aside a,
main nav a {
  font-size: inherit; /&#42; or whatever the default should be &#42;/
}

main aside p,
main nav p {
  line-height: inherit; /&#42; or whatever the default should be &#42;/
  color: inherit; /&#42; or a specific color &#42;/
}
</code></pre>
</div>

<p>Check out the following example. Do you notice how simple it is to target some nested selectors while exempting others?</p>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="wBWXggN"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [@scope example [forked]](https://codepen.io/smashingmag/pen/wBWXggN) by <a href="https://codepen.io/blakeeric">Blake Lundquist</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/wBWXggN">@scope example [forked]</a> by <a href="https://codepen.io/blakeeric">Blake Lundquist</a>.</figcaption>
</figure>

<p>Consider a scenario where unique styles need to be applied to slotted content within <a href="https://www.smashingmagazine.com/2025/07/web-components-working-with-shadow-dom/">web components</a>. When slotting content into a web component, that content becomes part of the Shadow DOM, but still inherits styles from the parent document. The developer might want to implement different styles depending on which web component the content is slotted into:</p>

<pre><code class="language-html">&lt;!-- Same &lt;user-card&gt; content, different contexts --&gt;
&lt;product-showcase&gt;
  &lt;user-card slot="reviewer"&gt;
    &lt;img src="avatar.jpg" slot="avatar"&gt;
    &lt;span slot="name"&gt;Jane Doe&lt;/span&gt;
  &lt;/user-card&gt;
&lt;/product-showcase&gt;

&lt;team-roster&gt;
  &lt;user-card slot="member"&gt;
    &lt;img src="avatar.jpg" slot="avatar"&gt;
    &lt;span slot="name"&gt;Jane Doe&lt;/span&gt;
  &lt;/user-card&gt;
&lt;/team-roster&gt;
</code></pre>

<p>In this example, the developer might want the <code>&lt;user-card&gt;</code> to have distinct styles only if it is rendered inside <code>&lt;team-roster&gt;</code>:</p>

<pre><code class="language-css">@scope (team-roster) {
  user-card {
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
  }
  
  user-card img {
    border-radius: 50%;
    width: 40px;
    height: 40px;
  }
}
</code></pre>

<h2 id="more-benefits">More Benefits</h2>

<p>There are additional ways that <code>@scope</code> can remove the need for class management without resorting to utilities or JavaScript-generated class names. For example, <code>@scope</code> opens up the possibility to easily <strong>target descendants of any selector</strong>, not just class names:</p>

<div class="break-out">
<pre><code class="language-css">/&#42; Only div elements with a direct child button are included in the root scope &#42;/
@scope (div:has(&gt; button)) {
  p {
    font-size: 14px;
  }
}
</code></pre>
</div>

<p>And they <strong>can be nested</strong>, creating scopes within scopes:</p>

<pre><code class="language-css">@scope (main) {
  p {
    font-size: 16px;
    color: black;
  }
  @scope (section) {
    p {
      font-size: 14px;
      color: blue;
    }
    @scope (.highlight) {
      p {
        background-color: yellow;
        font-weight: bold;
      }
    }
  }
}
</code></pre>

<p>Plus, the root scope can be easily referenced within the <code>@scope</code> rule:</p>

<div class="break-out">
<pre><code class="language-css">/&#42; Applies to elements inside direct child `section` elements of `main`, but stops at any direct `aside` that is a direct chiled of those sections &#42;/
@scope (main &gt; section) to (:scope &gt; aside) {
  p {
    background-color: lightblue;
    color: blue;
  }
  /&#42; Applies to ul elements that are immediate siblings of root scope  &#42;/
  :scope + ul {
    list-style: none;
  }
}
</code></pre>
</div>

<p>The <code>@scope</code> at-rule also introduces a new <strong>proximity</strong> dimension to CSS specificity resolution. In traditional CSS, when two selectors match the same element, the selector with the higher specificity wins. With <code>@scope</code>, when two elements have equal specificity, the one whose scope root is closer to the matched element wins. This eliminates the need to override parent styles by manually increasing an element’s specificity, since inner components naturally supersede outer element styles.</p>

<div class="break-out">
<pre><code class="language-html">&lt;style&gt;
  @scope (.container) {
    .title { color: green; } 
  }
  &lt;!-- The &lt;h2&gt; is closer to .container than to .sidebar so "color: green" wins. --&gt;
  @scope (.sidebar) {
    .title { color: red; }
  }
&lt;/style&gt;

&lt;div class="sidebar"&gt;
  &lt;div class="container"&gt;
    &lt;h2 class="title"&gt;Hello&lt;/h2&gt;
  &lt;/div&gt;
&lt;/div&gt;
</code></pre>
</div>
    

<h2 id="conclusion">Conclusion</h2>

<p>Utility-first CSS frameworks, such as Tailwind, work well for prototyping and smaller projects. Their benefits quickly diminish, however, when used in larger projects involving more than a couple of developers.</p>

<p>Front-end development has become increasingly overcomplicated in the last few years, and CSS is no exception. While the <code>@scope</code> rule isn’t a cure-all, it can reduce the need for complex tooling. When used in place of, or alongside strategic class naming, <code>@scope</code> can make it easier and more fun to write maintainable CSS.</p>

<h3 id="further-reading">Further Reading</h3>

<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@scope">CSS <code>@scope</code></a> (MDN)</li>
<li>“<a href="https://css-tricks.com/almanac/rules/s/scope/">CSS <code>@scope</code></a>”, Juan Diego Rodríguez (CSS-Tricks)</li>
<li><a href="https://www.firefox.com/en-US/firefox/146.0/releasenotes/">Firefox 146 Release Notes</a> (Firefox)</li>
<li><a href="https://caniuse.com/css-cascade-scope">Browser Support</a> (CanIUse)</li>
<li><a href="https://2024.stateofcss.com/en-US/tools/">Popular CSS Frameworks</a> (State of CSS 2024)</li>
<li>“<a href="https://css-tricks.com/the-c-in-css-the-cascade/">The “C” in CSS: Cascade</a>”, Thomas Yip (CSS-Tricks)</li>
<li><a href="https://getbem.com/introduction/">BEM Introduction</a> (Get BEM)</li>
</ul>

<div class="signature">
  <img src="https://www.smashingmagazine.com/images/logo/logo--red.png" alt="Smashing Editorial" width="35" height="46" loading="lazy" decoding="async" />
  <span>(gg, yk)</span>
</div>


              </article>
            </body>
          </html>
        ]]></content:encoded></item><item><author>Stefan Kaltenegger</author><title>Practical Use Of AI Coding Tools For The Responsible Developer</title><link>https://www.smashingmagazine.com/2026/01/practical-use-ai-coding-tools-responsible-developer/</link><pubDate>Fri, 30 Jan 2026 13:00:00 +0000</pubDate><guid>https://www.smashingmagazine.com/2026/01/practical-use-ai-coding-tools-responsible-developer/</guid><description>AI coding tools like agents can be valuable allies in everyday development work. They help handle time-consuming grunt work, guide you through large legacy codebases, and offer low-risk ways to implement features in previously unfamiliar programming languages. Here are practical, easy-to-apply techniques to help you use these tools to improve your workflow.</description><content:encoded><![CDATA[
          <html>
            <head>
              <meta charset="utf-8">
              <link rel="canonical" href="https://www.smashingmagazine.com/2026/01/practical-use-ai-coding-tools-responsible-developer/" />
              <title>Practical Use Of AI Coding Tools For The Responsible Developer</title>
            </head>
            <body>
              <article>
                <header>
                  <h1>Practical Use Of AI Coding Tools For The Responsible Developer</h1>
                  
                    
                    <address>Stefan Kaltenegger</address>
                  
                  <time datetime="2026-01-30T13:00:00&#43;00:00" class="op-published">2026-01-30T13:00:00+00:00</time>
                  <time datetime="2026-01-30T13:00:00&#43;00:00" class="op-modified">2026-05-12T16:11:33+00:00</time>
                </header>
                
                

<p>Over the last two years, my team at <a href="https://work.co/">Work &amp; Co</a> and I have been testing out and gradually integrating AI coding tools like Copilot, Cursor, Claude, and ChatGPT to help us ship web experiences that are used by the masses. Admittedly, after some initial skepticism and a few aha moments, various AI tools have found their way into my daily use. Over time, the list of applications where we found it made sense to let AI take over started to grow, so I decided to share some <strong>practical use cases</strong> for AI tools for what I call the “responsible developer”.</p>

<p>What do I mean by a <strong>responsible developer</strong>?</p>

<p>We have to make sure that we deliver quality code as expected by our stakeholders and clients. Our contributions (i.e., pull requests) should not become a burden on our colleagues who will have to review and test our work. Also, in case you work for a company: The tools we use need to be approved by our employer. Sensitive aspects like security and privacy need to be handled properly: Don’t paste secrets, customer data (PII), or proprietary code into tools without policy approval. Treat it like code from a stranger on the internet. Always test and verify.</p>

<p><strong>Note</strong>: <em>This article assumes some very basic familiarity with AI coding tools like Copilot inside VSCode or Cursor. If all of this sounds totally new and unfamiliar to you, the <a href="https://github.com/features/copilot/tutorials">Github Copilot video tutorials</a> can be a fantastic starting point for you.</em></p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/01-vscode-copilot.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="518"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/01-vscode-copilot.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/01-vscode-copilot.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/01-vscode-copilot.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/01-vscode-copilot.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/01-vscode-copilot.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/01-vscode-copilot.png"
			
			sizes="100vw"
			alt="Screenshot of of VSCode with Copilot Chat open in the right panel"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      View of VSCode with Copilot Chat open in the right panel. (<a href='https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/01-vscode-copilot.png'>Large preview</a>)
    </figcaption>
  
</figure>

<h2 id="helpful-applications-of-ai-coding-tools">Helpful Applications Of AI Coding Tools</h2>

<p><strong>Note</strong>: The following examples will mainly focus on working in JavaScript-based web applications like React, Vue, Svelte, or Angular.</p>

<h3 id="getting-an-understanding-of-an-unfamiliar-codebase">Getting An Understanding Of An Unfamiliar Codebase</h3>

<p>It’s not uncommon to work on established codebases, and joining a large legacy codebase can be intimidating. Simply open your project and your AI agent (in my case, Copilot Chat in VSCode) and start asking questions just like you would ask a colleague. In general, I like to talk to any AI agent just as I would to a fellow human.</p>

<p>Here is a more refined example prompt:</p>

<blockquote>“Give me a high-level architecture overview: entrypoints, routing, auth, data layer, build tooling. Then list 5 files to read in order. Treat explanations as hypotheses and confirm by jumping to referenced files.”</blockquote>

<p>You can keep asking follow-up questions like <em>“How does the routing work in detail?”</em> or <em>“Talk me through the authentication process and methods”</em> and it will lead you to helpful directions to shine some light into the dark of an unfamiliar codebase.</p>

<h3 id="triaging-breaking-changes-when-upgrading-dependencies">Triaging Breaking Changes When Upgrading Dependencies</h3>

<p>Updating npm packages, especially when they come with breaking changes, can be tedious and time-consuming work, and make you debug a fair amount of regressions. I recently had to upgrade the data visualization library <a href="plotly.js">plotly.js</a> up one major release version from version 2 to 3, and as a result of that, the axis labeling in some of the graphs stopped working.</p>

<p>I went on to ask ChatGPT:</p>

<blockquote>“I updated my Angular project that uses Plotly. I updated the plotly.js &mdash; dist package from version 2.35.2 to 3.1.0 &mdash; and now the labels on the x and y axis are gone. What happened?”</blockquote>

<p>The agent came back with a solution promptly (see for yourself below).</p>

<p><strong>Note</strong>: <em>I still verified the explanation against the official migration guide before shipping the fix.</em></p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/02-chatgpt-plotly.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="647"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/02-chatgpt-plotly.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/02-chatgpt-plotly.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/02-chatgpt-plotly.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/02-chatgpt-plotly.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/02-chatgpt-plotly.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/02-chatgpt-plotly.png"
			
			sizes="100vw"
			alt="Screenshot of the response from ChatGPT when prompted “I updated my Angular project that uses plotly. I updated the plotly package from plotly.js-dist: ^2.35.2 to plotly.js-dist: ^3.1.0, - and now the labels on the x and y axis are gone - what happened? Thought for 19s. Short answer: Plotly.js v3 removed the string shorthand for titles. You must use the new object form.”"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/02-chatgpt-plotly.png'>Large preview</a>)
    </figcaption>
  
</figure>

<h3 id="replicating-refactors-safely-across-files">Replicating Refactors Safely Across Files</h3>

<p>Growing codebases most certainly unveil opportunities for code consolidation. For example, you notice code duplication across files that can be extracted into a single function or component. As a result, you decide to create a shared component that can be included instead and perform that refactor in one file. Now, instead of manually carrying out those changes to your remaining files, you ask your agent to roll out the refactor for you.</p>

<p>Agents let you select multiple files as context. Once the refactor for one file is done, I can add both the refactored and untouched files into context and prompt the agent to roll out the changes to other files like this: <em>“Replicate the changes I made in file A to file B as well”</em>.</p>

<h3 id="implementing-features-in-unfamiliar-technologies">Implementing Features In Unfamiliar Technologies</h3>

<p>One of my favorite aha-moments using AI coding tools was when it helped me create a quite complex animated gradient animation in GLSL, a language I have been fairly unfamiliar with. On a recent project, our designers came up with an animated gradient as a loading state on a 3D object. I really liked the concept and wanted to deliver something unique and exciting to our clients. The problem: I only had two days to implement it, and GLSL has quite the steep learning curve.</p>

<p>Again, an AI tool (in this case, ChatGPT) came in handy, and I started quite simply prompting it to create a standalone HTML file for me that renders a canvas and a very simple animated color gradient. Step after step, I prompted the AI to add more finesse to it until I arrived at a decent result so I could start integrating the shader into my actual codebase.</p>

<p>The end result: Our clients were super happy, and we delivered a complex feature in a small amount of time thanks to AI.</p>

<h3 id="writing-tests">Writing Tests</h3>

<p>In my experience, there’s rarely enough time on projects to continuously write and maintain a proper suite of unit and integration tests, and on top of that, many developers don’t really enjoy the task of writing tests. Prompting your AI helper to set up and write tests for you is entirely possible and can be done in a small amount of time. Of course, you, as a developer, should still make sure that your tests actually take a look at the critical parts of your application and follow sensible testing principles, but you can “outsource” the writing of the tests to our AI helper.</p>

<p>Example prompt:</p>

<blockquote>“Write unit tests for this function using Jest. Cover happy path, edge cases, and failure modes. Explain why each test exists.”</blockquote>

<p>You can even pass along testing guru Kent C. Dodds’ testing best practices as guidelines to your agent, like below:</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://x.com/kentcdodds/status/2008940437747409406">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="795"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/03-post-testing.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/03-post-testing.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/03-post-testing.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/03-post-testing.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/03-post-testing.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/03-post-testing.png"
			
			sizes="100vw"
			alt="A post on X by @kentcdodds that reads “When telling AI to write tests, I find those tests improve a lot when I send: Could you apply these principles to the tests? https://kentcdodds.com/blog/avoid-nesting-when-youre-testing and https://epicweb.dev/better-test-setup-with-disposable-objects. Just did it and the tests got much more clear and terse.”"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Image source: <a href='https://x.com/kentcdodds/status/2008940437747409406'>x.com/kentcdodds</a>. (<a href='https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/03-post-testing.png'>Large preview</a>)
    </figcaption>
  
</figure>

<h3 id="internal-tooling">Internal Tooling</h3>

<p>Somewhat similar to the shader example mentioned earlier, I was recently tasked to analyze code duplication in a codebase and compare before and after a refactor. Certainly not a trivial task if you don’t want to go the time-consuming route of comparing files manually. With the help of Copilot, I created a script that analyzed code duplication for me, arranged and ordered the output in a table, and exported it to Excel. Then I took it a step further. When our code refactor was done, I prompted the agent to take my existing Excel sheet as the baseline, add in the current state of duplication in separate columns, and calculate the delta.</p>

<h3 id="updating-code-written-a-long-time-ago">Updating Code Written A Long Time Ago</h3>

<p>Recently, an old client of mine hit me up, as over time, a few features weren’t working properly on his website anymore.</p>

<p>The catch: The website was built almost ten years ago, and the JavaScript and SCSS were using rather old compile tools like requireJS, and the setup required an older version of Node.js that wouldn’t even run on my 2025 MacBook.</p>

<p>Updating the whole build process by hand would have taken me days, so I decided to prompt the AI agent, <em>“Can you update the JS and SCSS build process to a lean 2025 stack like Vite?”</em> It sure did, and after around an hour of refining with the agent, I had my SCSS and JS build switched to Vite, and I was able to focus on actual bugfixing. Just make sure to properly validate the output and compiled files when doing such integral changes to your build process.</p>

<h3 id="summarizing-and-drafting">Summarizing And Drafting</h3>

<p>Would you like to summarize all your recent code changes in one sentence for a commit message, or have a long list of commits and would like to sum them up in three bullet points? No problem, let the AI take care of it, but please make sure to proofread it.</p>

<p>An example prompt is as simple as messaging a fellow human: <em>“Please sum up my recent changes in concise bullet points”</em>.</p>

<p>My advice here would be to use GPT for writing with caution, and as with code, please check the output before sending or submitting.</p>

<h2 id="recommendations-and-best-practices">Recommendations And Best Practices</h2>

<h3 id="prompting">Prompting</h3>

<p>One of the not-so-obvious benefits of using AI is that the more specific and tailored your prompts are, the better the output. The process of prompting an AI agent forces us to <strong>formulate our requirements as specifically as possible</strong> before we write and code. This is why, as a general rule, I highly recommend being as specific as possible with your prompting.</p>

<p>Ryan Florence, co-author of Remix, suggests a simple yet powerful way to improve this process by finishing your initial prompt with the sentence:</p>

<blockquote>“Before we start, do you have any questions for me?”</blockquote>

<p>At this point, the AI usually comes back with helpful questions where you can clarify your specific intent, guiding the agent to provide you with a <strong>more tailored approach</strong> for your task.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://x.com/ryanflorence/status/1959407056286568828">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="690"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/04-post-cursor.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/04-post-cursor.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/04-post-cursor.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/04-post-cursor.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/04-post-cursor.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/04-post-cursor.png"
			
			sizes="100vw"
			alt="A post on X by @ryanflorence that reads: “I always ask Cursor: Do you have any questions before you begin to implement this? Usually, some good questions arise, I answer them, and have the model update the feature explanation doc and spec doc. Eventually, it has no more questions and often nails the sessions.”"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Image source: <a href='https://x.com/ryanflorence/status/1959407056286568828'>x.com/ryanflorence</a>. (<a href='https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/04-post-cursor.png'>Large preview</a>)
    </figcaption>
  
</figure>

<h3 id="use-version-control-and-work-in-digestible-chunks">Use Version Control And Work In Digestible Chunks</h3>

<p>Using version control like git not only comes in handy when collaborating as a team on a single codebase but also to provide you as an individual contributor with stable points to roll back to in case of an emergency. Due to its non-deterministic nature, AI can sometimes go rogue and make changes that are simply not helpful for what you are trying to achieve and eventually break things irreparably.</p>

<p>Splitting up your work into <strong>multiple commits</strong> will help you create stable points that you can revert to in case things go sideways. And your teammates will thank you as well, as they will have an easier time reviewing your code when it is split up into semantically well-structured chunks.</p>

<h3 id="review-thoroughly">Review Thoroughly</h3>

<p>This is more of a general best practice, but in my opinion, it becomes even more important when using AI tools for development work: <strong>Be the first critical reviewer of your code</strong>. Make sure to take some time to go over your changes line by line, just like you would review someone else’s code, and only submit your work once it passes your own self-review.</p>

<blockquote>“Two things are both true to me right now: AI agents are amazing and a huge productivity boost. They are also massive slop machines if you turn off your brain and let go completely.”<br /><br />&mdash; Armin Ronacher in his blog post <a href="https://lucumr.pocoo.org/2026/1/18/agent-psychosis/">Agent Psychosis: Are We Going Insane?</a></blockquote>

<h2 id="conclusion-and-critical-thoughts">Conclusion And Critical Thoughts</h2>

<p>In my opinion, AI coding tools can improve our productivity as developers on a daily basis and free up mental capacity for more planning and high-level thinking. They force us to articulate our desired outcome with meticulous detail.</p>

<p>Any AI can, at times, hallucinate, which basically means it lies in a confident tone. So please make sure to check and test, especially when you are in doubt. AI is not a silver bullet, and I believe, excellence and the ability to solve problems as a developer will never go out of fashion.</p>

<p>For developers who are just starting out in their career these tools can be highly tempting to do the majority of the work for them. What may get lost here is the often draining and painful work through bugs and issues that are tricky to debug and solve, aka “the grind”. Even Cursor AI’s very own Lee Robinson questions this in one of his posts:</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://x.com/leerob/status/1996281383535382909">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="1093"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/05-post-leerob.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/05-post-leerob.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/05-post-leerob.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/05-post-leerob.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/05-post-leerob.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/05-post-leerob.png"
			
			sizes="100vw"
			alt="A post on X by @leerob that reads: “My biggest worries about coding with AI: 1. Beginners not actually learning 2. Atrophy of skills I’m seeing #1 happen, and I don’t have a good answer yet. Leveling up as an engineer requires grinding, and it’s not always fun. If AI can solve most of the problems for you, when do you lean into the healthy friction? When do you embrace the suck? Coupled with fewer opportunities for pair programming, it’s definitely tougher for those starting their engineering career.”"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Image source: <a href='https://x.com/leerob/status/1996281383535382909'>x.com/leerob</a>. (<a href='https://files.smashing.media/articles/practical-use-ai-coding-tools-responsible-developer/05-post-leerob.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>AI coding tools are evolving at a fast pace, and I am excited for what will come next. I hope you found this article and its tips helpful and are excited to try out some of these for yourself.</p>

<div class="signature">
  <img src="https://www.smashingmagazine.com/images/logo/logo--red.png" alt="Smashing Editorial" width="35" height="46" loading="lazy" decoding="async" />
  <span>(yk)</span>
</div>


              </article>
            </body>
          </html>
        ]]></content:encoded></item><item><author>Gabriel Shoyombo</author><title>Unstacking CSS Stacking Contexts</title><link>https://www.smashingmagazine.com/2026/01/unstacking-css-stacking-contexts/</link><pubDate>Tue, 27 Jan 2026 10:00:00 +0000</pubDate><guid>https://www.smashingmagazine.com/2026/01/unstacking-css-stacking-contexts/</guid><description>In CSS, we can create “stacking contexts” where elements are visually placed one on top of the next in a three-dimensional sense that creates the perception of depth. Stacking contexts are incredibly useful, but they’re also widely misunderstood and often mistakenly created, leading to a slew of layout issues that can be tricky to solve.</description><content:encoded><![CDATA[
          <html>
            <head>
              <meta charset="utf-8">
              <link rel="canonical" href="https://www.smashingmagazine.com/2026/01/unstacking-css-stacking-contexts/" />
              <title>Unstacking CSS Stacking Contexts</title>
            </head>
            <body>
              <article>
                <header>
                  <h1>Unstacking CSS Stacking Contexts</h1>
                  
                    
                    <address>Gabriel Shoyombo</address>
                  
                  <time datetime="2026-01-27T10:00:00&#43;00:00" class="op-published">2026-01-27T10:00:00+00:00</time>
                  <time datetime="2026-01-27T10:00:00&#43;00:00" class="op-modified">2026-05-12T16:11:33+00:00</time>
                </header>
                
                

<p>Have you ever set <code>z-index: 99999</code> on an element in your CSS, and it doesn’t come out on top of other elements? A value that large should easily place that element visually on top of anything else, assuming all the different elements are set at either a lower value or not set at all.</p>

<p>A webpage is usually represented in a two-dimensional space; however, by applying specific CSS properties, an imaginary z-axis plane is introduced to convey depth. This plane is perpendicular to the screen, and from it, the user perceives the order of elements, one on top of the other. The idea behind the imaginary z-axis, the user’s perception of stacked elements, is that the CSS properties that create it combine to form what we call a <strong>stacking context</strong>.</p>

<p>We’re going to talk about how elements are “stacked” on a webpage, what controls the stacking order, and practical approaches to “unstack” elements when needed.</p>

<h2 id="about-stacking-contexts">About Stacking Contexts</h2>

<p>Imagine your webpage as a desk. As you add HTML elements, you’re laying pieces of paper, one after the other, on the desk. The last piece of paper placed is equivalent to the most recently added HTML element, and it sits on top of all the other papers placed before it. This is the normal document flow, even for nested elements. The desk itself represents the root stacking context, formed by the <code>&lt;html&gt;</code> element, which contains all other folders.</p>

<p>Now, specific CSS properties come into play.</p>

<p>Properties like <code>position</code> (with <code>z-index</code>), <code>opacity</code>, <code>transform</code>, and <code>contain</code>) act like a folder. This folder takes an element and all of its children, extracts them from the main stack, and groups them into a separate sub-stack, creating what we call a <strong>stacking context</strong>. For positioned elements, this happens when we declare a <code>z-index</code> value other than <code>auto</code>. For properties like <code>opacity</code>, <code>transform</code>, and <code>filter</code>, the stacking context is created automatically when specific values are applied.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/unstacking-css-stacking-contexts/1-stacking-context-order.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="436"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/unstacking-css-stacking-contexts/1-stacking-context-order.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/unstacking-css-stacking-contexts/1-stacking-context-order.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/unstacking-css-stacking-contexts/1-stacking-context-order.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/unstacking-css-stacking-contexts/1-stacking-context-order.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/unstacking-css-stacking-contexts/1-stacking-context-order.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/unstacking-css-stacking-contexts/1-stacking-context-order.png"
			
			sizes="100vw"
			alt="Before (global stacking order) and after (stacking context order)"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      When the browser decides what goes on top, it stacks the folders first, not the individual papers inside them. This is “The Golden Rule” of stacking contexts that many developers miss. (<a href='https://files.smashing.media/articles/unstacking-css-stacking-contexts/1-stacking-context-order.png'>Large preview</a>)
    </figcaption>
  
</figure>

<blockquote>Try to understand this: Once a piece of paper (i.e., a child element) is inside a folder (i.e., the parent’s stacking context), it can never exit that folder or be placed between papers in a different folder. Its <code>z-index</code> is now only relevant inside its own folder.</blockquote>

<p>In the illustration below, Paper B is now within the stacking context of Folder B, and can only be ordered with other papers in the folder.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/unstacking-css-stacking-contexts/2-stacking-contexts.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="436"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/unstacking-css-stacking-contexts/2-stacking-contexts.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/unstacking-css-stacking-contexts/2-stacking-contexts.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/unstacking-css-stacking-contexts/2-stacking-contexts.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/unstacking-css-stacking-contexts/2-stacking-contexts.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/unstacking-css-stacking-contexts/2-stacking-contexts.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/unstacking-css-stacking-contexts/2-stacking-contexts.png"
			
			sizes="100vw"
			alt="Before (global stacking order) and after (stacking context order)"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/unstacking-css-stacking-contexts/2-stacking-contexts.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>Imagine, if you will, that you have two folders on your desk:</p>

<pre><code class="language-html">&lt;div class="folder-a"&gt;Folder A&lt;/div&gt;
&lt;div class="folder-b"&gt;Folder B&lt;/div&gt;
</code></pre>

<pre><code class="language-css">.folder-a { z-index: 1; }
.folder-b { z-index: 2; }
</code></pre>

<p>Let’s update the markup a bit. Inside Folder A is a special page, <code>z-index: 9999</code>. Inside Folder B is a plain page, <code>z-index: 5</code>.</p>

<pre><code class="language-html">&lt;div class="folder-a"&gt;
   &lt;div class="special-page"&gt;Special Page&lt;/div&gt;
&lt;/div&gt;

&lt;div class="folder-b"&gt;
  &lt;div class="plain-page"&gt;Plain Page&lt;/div&gt;
&lt;/div&gt;
</code></pre>

<pre><code class="language-css">.special-page { z-index: 9999; }
.plain-page { z-index: 5; }
</code></pre>

<p>Which page is on top?</p>

<p>It’s the <code>.plain-page</code> in Folder B. The browser ignores the child papers and stacks the two folders first. It sees Folder B (<code>z-index: 2</code>) and places it on top of Folder A (<code>z-index: 1</code>) because we know that two is greater than one. Meanwhile, the <code>.special-page</code> set to <code>z-index: 9999</code> page is at the bottom of the stack even though its <code>z-index</code> is set to the highest possible value.</p>

<p>Stacking contexts can also be nested (folders inside folders), creating a “family tree.” The same principle applies: a child can never escape its parents’ folder.</p>

<p>Now that you get how stacking contexts behave like folders that group and reorder layers, it’s worth asking: why do certain properties &mdash; like <code>transform</code> and <code>opacity</code> &mdash; create new stacking contexts?</p>

<p>Here’s the thing: these properties don’t create stacking contexts because of how they look; they do it because of how the browser works under the hood. When you apply <code>transform</code>, <code>opacity</code>, <code>filter</code>, or <code>perspective</code>, you’re telling the browser, <em>“Hey, this element might move, rotate, or fade, so be ready!”</em></p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/unstacking-css-stacking-contexts/3-diagram-stacking-context.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="533"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/unstacking-css-stacking-contexts/3-diagram-stacking-context.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/unstacking-css-stacking-contexts/3-diagram-stacking-context.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/unstacking-css-stacking-contexts/3-diagram-stacking-context.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/unstacking-css-stacking-contexts/3-diagram-stacking-context.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/unstacking-css-stacking-contexts/3-diagram-stacking-context.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/unstacking-css-stacking-contexts/3-diagram-stacking-context.png"
			
			sizes="100vw"
			alt="Diagram illustrating the main document layout with an applied transform that creates a new stacking context, which in turn, runs on the GPU to handle the transformation. It indicates that a new stacking context is promoted for performance."
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/unstacking-css-stacking-contexts/3-diagram-stacking-context.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>When you use these properties, the browser creates a new stacking context to manage rendering more efficiently. This allows the browser to handle animations, transforms, and visual effects independently, reducing the need to recalculate how these elements interact with the rest of the page. Think of it as the browser saying, <em>“I’ll handle this folder separately so I don’t have to reshuffle the entire desk every time something inside it changes.”</em></p>

<p>But there’s a side effect. Once the browser lifts an element into its own layer, it must “flatten” everything within it, creating a new stacking context. It’s like taking a folder off the desk to handle it separately; everything inside that folder gets grouped, and the browser now treats it as a single unit when deciding what sits on top of what.</p>

<p>So even though the <code>transform</code> and <code>opacity</code> properties might not appear to affect the way that elements stack visually, they do, and it’s for performance optimisation. Several other CSS properties can also create stacking contexts for similar reasons. <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Stacking_context#features_creating_stacking_contexts">MDN provides a complete list</a> if you want to dig deeper. There are quite a few, which only illustrates how easy it is to inadvertently create a stacking context without knowing it.</p>

<div data-audience="non-subscriber" data-remove="true" class="feature-panel-container">

<aside class="feature-panel" style="">
<div class="feature-panel-left-col">

<div class="feature-panel-description"><p>Meet <strong><a data-instant href="https://www.smashingconf.com/online-workshops/">Smashing Workshops</a></strong> on <strong>front-end, design &amp; UX</strong>, with practical takeaways, live sessions, <strong>video recordings</strong> and a friendly Q&amp;A. With Brad Frost, Stéph Walter and <a href="https://smashingconf.com/online-workshops/workshops">so many others</a>.</p>
<a data-instant href="smashing-workshops" class="btn btn--green btn--large" style="">Jump to the workshops&nbsp;↬</a></div>
</div>
<div class="feature-panel-right-col"><a data-instant href="smashing-workshops" class="feature-panel-image-link">
<div class="feature-panel-image">
<img
    loading="lazy"
    decoding="async"
    class="feature-panel-image-img"
    src="/images/smashing-cat/cat-scubadiving-panel.svg"
    alt="Feature Panel"
    width="257"
    height="355"
/>

</div>
</a>
</div>
</aside>
</div>

<h2 id="the-unstacking-problem">The “Unstacking” Problem</h2>

<p>Stacking issues can arise for many reasons, but some are more common than others. Modal components are a classic pattern because they require toggling the component to “open” on a top layer above all other elements, then removing it from the top layer when it is “closed.”</p>

<p>I’m pretty confident that all of us have run into a situation where we open a modal and, for whatever reason, it doesn’t appear. It’s not that it didn’t open properly, but that it is out of view in a lower layer of the stacking context.</p>

<p>This leaves you to wonder “how come?” since you set:</p>

<div class="break-out">
<pre><code class="language-css">.overlay {
  position: fixed; /&#42; creates the stacking context &#42;/
  z-index: 1; /&#42; puts the element on a layer above everything else &#42;/
  inset: 0; 
  width: 100%; 
  height: 100vh; 
  overflow: hidden;
  background-color: &#35;00000080;
}
</code></pre>
</div>

<p>This looks correct, but if the parent element containing the modal trigger is a child element within another parent element that’s also set to <code>z-index: 1</code>, that technically places the modal in a sublayer obscured by the main folder. Let’s look at that specific scenario and a couple of other common stacking-context pitfalls. I think you’ll see not only how easy it is to inadvertently create stacking contexts, but also how to mismanage them. Also, how you return to a managed state depends on the situation.</p>

<h3 id="scenario-1-the-trapped-modal">Scenario 1: The Trapped Modal</h3>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="pvbddjd"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [Scenario 1: The Trapped Modal (Problem) [forked]](https://codepen.io/smashingmag/pen/pvbddjd) by <a href="https://codepen.io/drprime01">Shoyombo Gabriel Ayomide</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/pvbddjd">Scenario 1: The Trapped Modal (Problem) [forked]</a> by <a href="https://codepen.io/drprime01">Shoyombo Gabriel Ayomide</a>.</figcaption>
</figure>

<p>If you click the “Open Modal” button in the header, you’ll notice that the overlay and modal appear behind the main content. This is because the modal is a child of the header container, which has a lower stacking context order (<code>z-index: 1</code>) than the main container (<code>z-index</code> of <code>2</code>). Despite the modal overlay and the modal having <code>z-index</code> values of <code>9998</code> and <code>9999</code>, respectively, the main container with a <code>z-index: 2</code> still sits right above them.</p>

<h3 id="scenario-2-the-submerged-dropdown">Scenario 2: The Submerged Dropdown</h3>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="zxBPPvm"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [Scenario 2: The Submerged Dropdown (Problem) [forked]](https://codepen.io/smashingmag/pen/zxBPPvm) by <a href="https://codepen.io/drprime01">Shoyombo Gabriel Ayomide</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/zxBPPvm">Scenario 2: The Submerged Dropdown (Problem) [forked]</a> by <a href="https://codepen.io/drprime01">Shoyombo Gabriel Ayomide</a>.</figcaption>
</figure>

<p>Here, we have a similar issue with the first scenario. When you hover over the “services” link, the dropdown shows, but behind the main container. I intentionally set the main container’s <code>margin-top</code> to <code>20px</code> to make the dropdown visible enough for you to see it appear, but keep it just behind the main container. This is another popular issue front-end developers encounter, stemming from context stacking. While it is similar to the first scenario, there’s another approach to resolving it, which will be explored soon.</p>

<h3 id="scenario-3-the-clipped-tooltip">Scenario 3: The Clipped Tooltip</h3>

<p>Now, this is an interesting one. It’s not about which element has the higher <code>z-index</code>. It’s about <code>overflow: hidden</code> doing <a href="https://www.smashingmagazine.com/2021/04/css-overflow-issues/">what it’s designed to do</a>: preventing content from visually escaping its container, even when that content has <code>z-index: 1000</code>.</p>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="GgqOOoo"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [Scenario 3: The Clipped Tooltip (Problem) [forked]](https://codepen.io/smashingmag/pen/GgqOOoo) by <a href="https://codepen.io/drprime01">Shoyombo Gabriel Ayomide</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/GgqOOoo">Scenario 3: The Clipped Tooltip (Problem) [forked]</a> by <a href="https://codepen.io/drprime01">Shoyombo Gabriel Ayomide</a>.</figcaption>
</figure>

<p>Who would have thought <code>overflow: hidden</code> could stop a <code>z-index: 1000</code>? Well, it did stop it, as you can see in the Codepen above.</p>

<p>I think developers trust <code>z-index</code> so much that they expect it to pull them out of any obscurity issue, but in reality, it doesn’t work that way. Not that it isn’t powerful, it’s just that other factors determine its ability to push your element to the top.</p>

<p>Before you slap <code>z-index</code> on that element, remember that while this might get you out of the current jam, it might also throw you into a greater one <a href="https://www.matuzo.at/blog/2025/never-lose-a-z-index-battle-again">that even <code>z-index: infinity</code> won’t get you out of</a>.</p>

<p>Let’s try to understand the problem before attempting to fix it.</p>

<div class="partners__lead-place"></div>

<h2 id="identifying-the-trapped-layer">Identifying The Trapped Layer</h2>

<p>When you encounter an issue such as those listed above, it is helpful to know that the element isn’t possessed; instead, an ancestor has sinned, and the child is paying the debt. In non-spiritual English terms, the obscured element isn’t the problem; an ancestor element has created a lower-level stacking context that has led the children to be below the children of a parent with a higher-level stacking context.</p>

<p>A good way to track and find that parent is to descend into the browser’s devtools to inspect the element and make your way up, checking each parent level to see which has a property or properties that trigger a stacking context, and find out its position in the order compared to sibling elements. Let’s create a checklist to order our steps.</p>

<h3 id="your-debugging-checklist">Your Debugging Checklist</h3>

<ol>
<li><strong>Inspect the Problem Element.</strong><br />
Right-click your hidden element (the modal, the dropdown menu, the tooltip) and click “Inspect.”</li>
<li><strong>Check its Styles.</strong><br />
In the “Styles” or “Computed” pane, verify that it has the expected high <code>z-index</code> (e.g., <code>z-index: 9999;</code>).</li>
<li><strong>Climb the DOM Tree.</strong><br />
In the “Elements” panel, look at the element’s immediate parent. Click on it.</li>
<li><strong>Investigate the Parent’s Styles.</strong><br />
Look at the parent’s CSS in the “Styles” pane. You are now hunting for any property that creates a new stacking context. Look for any properties related to positioning, visual effects, and containment.</li>
<li><strong>Repeat.</strong><br />
If the immediate parent is clean, click on its parent (the grandparent of your element). Repeat Step 4. Keep climbing the DOM tree, one parent at a time, until you find the culprit.</li>
</ol>

<p>Now, let’s apply this checklist to our three scenarios.</p>

<h3 id="problem-1-the-trapped-modal">Problem 1: The Trapped Modal</h3>

<ol>
<li><strong>Inspect:</strong> We inspect the <code>.modal-content</code>.</li>
<li><strong>Check Styles:</strong> We see <code>z-index: 9999</code>. That’s not the problem.</li>
<li><strong>Climb:</strong> We look at its parent, <code>.modal-container</code>. It has no trapping properties.</li>
<li><strong>Climb Again:</strong> We look at its parent, the <strong><code>.header</code></strong>.</li>
<li><strong>Investigate:</strong> We check the styles for <code>.header</code> and find the culprit: <code>position: absolute</code> and <code>z-index: 1</code>. This element is creating a stacking context. We’ve seen our trap! The modal’s <code>z-index: 9999</code> is “trapped” inside a <code>z-index: 1</code> folder.</li>
</ol>

<h3 id="problem-2-the-submerged-dropdown">Problem 2: The Submerged Dropdown</h3>

<ol>
<li><strong>Inspect:</strong> We inspect the <code>.dropdown-menu</code>.</li>
<li><strong>Check Styles:</strong> We see <code>z-index: 100</code>.</li>
<li><strong>Climb:</strong> We check its parent <code>li</code>, then its parent <code>ul</code>, then its parent <strong><code>.navbar</code></strong>.</li>
<li>Investigate: We find <code>.navbar</code> has <code>position: relative</code> and <code>z-index: 1</code>. This creates Stacking Context A.</li>
<li><strong>Analyse Siblings:</strong> This isn’t the whole story. Why is it under the content? We now inspect the sibling of <code>.navbar</code>, which is <code>.content</code>. We find it has <code>position: relative</code> and <code>z-index: 2</code> (Stacking Context B). The browser is stacking the “folders”: <code>.content</code> (2) on top of <code>.navbar</code> (1). We’ve found the root cause.</li>
</ol>

<h3 id="problem-3-the-clipped-tooltip">Problem 3: The Clipped Tooltip</h3>

<ol>
<li><strong>Inspect:</strong> We inspect the <code>.tooltip</code>.</li>
<li><strong>Check Styles:</strong> We see <code>z-index: 1000</code>.</li>
<li><strong>Climb:</strong> We check its parent, <code>.tooltip-trigger</code>. It’s fine.</li>
<li><strong>Climb Again:</strong> We check its parent, the <strong><code>.card-container</code></strong>.</li>
<li><strong>Investigate:</strong> We scan its styles and find the culprit: <code>overflow: hidden</code>. This is a special type of trap. It clips any child that tries to render outside its boundaries, regardless of <code>z-index</code> values.</li>
</ol>

<h3 id="advanced-tooling">Advanced Tooling</h3>

<p>While climbing the DOM tree works, it can be slow. Here are tools that speed things up.</p>

<h4 id="devtools-3d-view">DevTools 3D View</h4>

<p>Some browsers, such as Microsoft Edge (in the “More Tools” menu) and Firefox (in the “Inspector” tab), include a “3D View” or “Layers” panel. This tool is a lifesaver. It visually explodes the webpage into its different layers, showing you exactly how the stacking contexts are grouped.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/unstacking-css-stacking-contexts/4-microsoft-edge-stacking-context.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="534"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/unstacking-css-stacking-contexts/4-microsoft-edge-stacking-context.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/unstacking-css-stacking-contexts/4-microsoft-edge-stacking-context.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/unstacking-css-stacking-contexts/4-microsoft-edge-stacking-context.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/unstacking-css-stacking-contexts/4-microsoft-edge-stacking-context.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/unstacking-css-stacking-contexts/4-microsoft-edge-stacking-context.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/unstacking-css-stacking-contexts/4-microsoft-edge-stacking-context.png"
			
			sizes="100vw"
			alt="Microsoft Edge 3D Stacking Context View"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Microsoft Edge 3D Stacking Context View. (<a href='https://files.smashing.media/articles/unstacking-css-stacking-contexts/4-microsoft-edge-stacking-context.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>You can immediately see your modal trapped in a low-level layer and identify the parent.</p>

<h4 id="browser-extensions">Browser Extensions</h4>

<p>Smart developers have built extensions to help. Tools like this <a href="https://chrome.google.com/webstore/detail/z-context/jigamimbjojkdgnlldajknogfgncplbhhttps://chrome.google.com/webstore/detail/z-context/jigamimbjojkdgnlldajknogfgncplbh">“CSS Stacking Context Inspector” Chrome extension</a> add an extra <code>z-index</code> tab to your DevTools to show you information about elements that create a stacking context.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/unstacking-css-stacking-contexts/5-browser-extensions.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="341"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/unstacking-css-stacking-contexts/5-browser-extensions.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/unstacking-css-stacking-contexts/5-browser-extensions.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/unstacking-css-stacking-contexts/5-browser-extensions.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/unstacking-css-stacking-contexts/5-browser-extensions.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/unstacking-css-stacking-contexts/5-browser-extensions.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/unstacking-css-stacking-contexts/5-browser-extensions.png"
			
			sizes="100vw"
			alt="CSS Stacking Context Inspector Chrome extension"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/unstacking-css-stacking-contexts/5-browser-extensions.png'>Large preview</a>)
    </figcaption>
  
</figure>

<h4 id="ide-extensions">IDE Extensions</h4>

<p>You can even spot issues during development with an extension <a href="https://marketplace.visualstudio.com/items?itemName=mikerheault.vscode-better-css-stacking-contexts">like this one for VS Code</a>, which highlights potential stacking context issues directly in your editor.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/unstacking-css-stacking-contexts/6-ide-extensions.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="468"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/unstacking-css-stacking-contexts/6-ide-extensions.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/unstacking-css-stacking-contexts/6-ide-extensions.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/unstacking-css-stacking-contexts/6-ide-extensions.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/unstacking-css-stacking-contexts/6-ide-extensions.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/unstacking-css-stacking-contexts/6-ide-extensions.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/unstacking-css-stacking-contexts/6-ide-extensions.png"
			
			sizes="100vw"
			alt="Better CSS Stacking Contexts Extension"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Better CSS Stacking Contexts Extension. (<a href='https://files.smashing.media/articles/unstacking-css-stacking-contexts/6-ide-extensions.png'>Large preview</a>)
    </figcaption>
  
</figure>

<h2 id="unstacking-and-regaining-control">Unstacking And Regaining Control</h2>

<p>After we’ve identified the root cause, the next step is to deal with it. There are several approaches you can take to tackle this problem, and I’ll list them in order. You can choose anyone at any level, though; no one can complain or obstruct another.</p>

<h3 id="change-the-html-structure">Change The HTML Structure</h3>

<p>This is considered the optimal fix. For you to run into a stacking context issue, you must have placed some elements in funny positions within your HTML. Restructuring the page will help you reshape the DOM and eliminate the stacking context problem. Find the problematic element and remove it from the trapping element in the HTML markup. For instance, we can solve the first scenario, “The Trapped Modal,” by moving the <code>.modal-container</code> out of the header and placing it in the <code>&lt;body&gt;</code> element by itself.</p>

<div class="break-out">
<pre><code class="language-html">&lt;header class="header"&gt;
  &lt;h2&gt;Header&lt;/h2&gt;
  &lt;button id="open-modal"&gt;Open Modal&lt;/button&gt;
  &lt;!-- Former position --&gt;
&lt;/header&gt;
&lt;main class="content"&gt;
  &lt;h1&gt;Main Content&lt;/h1&gt;
  &lt;p&gt;This content has a z-index of 2 and will still not cover the modal.&lt;/p&gt;
&lt;/main&gt;

&lt;!-- New position  --&gt;
&lt;div id="modal-container" class="modal-container"&gt;
  &lt;div class="modal-overlay"&gt;&lt;/div&gt;
  &lt;div class="modal-content"&gt;
    &lt;h3&gt;Modal Title&lt;/h3&gt;
    &lt;p&gt;Now, I'm not behind anything. I've gotten a better position as a result of DOM restructuring.&lt;/p&gt;
    &lt;button id="close-modal"&gt;Close&lt;/button&gt;
  &lt;/div&gt;
&lt;/div&gt;
</code></pre>
</div> 

<p>When you click the “Open Modal” button, the modal is positioned in front of everything else as it’s supposed to be.</p>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="azZVVNP"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [Scenario 1: The Trapped Modal (Solution) [forked]](https://codepen.io/smashingmag/pen/azZVVNP) by <a href="https://codepen.io/drprime01">Shoyombo Gabriel Ayomide</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/azZVVNP">Scenario 1: The Trapped Modal (Solution) [forked]</a> by <a href="https://codepen.io/drprime01">Shoyombo Gabriel Ayomide</a>.</figcaption>
</figure>

<h3 id="adjust-the-parent-stacking-context-in-css">Adjust The Parent Stacking Context In CSS</h3>

<p>What if the element is one you can’t move without breaking the layout? It’s better to address the issue: <strong>the parent establishes the context.</strong> Find the CSS property (or properties) responsible for triggering the context and remove it. If it has a purpose and cannot be removed, give the parent a higher <code>z-index</code> value than its sibling elements to lift the entire container. With a higher <code>z-index</code> value, the parent container moves to the top, and its children appear closer to the user.</p>

<p>Based on what we learned in “<a href="/scl/fi/ue0rufxffviprc9858j25/Debugging-CSS-Stacking-Contexts.paper?rlkey=ezbdaiq6mojvb7xzezxlds29b&amp;dl=0#:uid=376729122027939635792428&amp;h2=Problem-2:-The-Submerged-Dropd">The Submerged Dropdown</a>” scenario, we can’t move the dropdown out of the navbar; it wouldn’t make sense. However, we can increase the <code>z-index</code> value of the <code>.navbar</code> container to be greater than the <code>.content</code> element’s <code>z-index</code> value.</p>

<pre><code class="language-css">.navbar {
  background: &#35;333;
  /&#42; z-index: 1; &#42;/
  z-index: 3;
  position: relative;
}
</code></pre>

<p>With this change, the <code>.dropdown-menu</code> now appears in front of the content without any issue.</p>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="YPWEEWz"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [Scenario 2: The Submerged Dropdown (Solution) [forked]](https://codepen.io/smashingmag/pen/YPWEEWz) by <a href="https://codepen.io/drprime01">Shoyombo Gabriel Ayomide</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/YPWEEWz">Scenario 2: The Submerged Dropdown (Solution) [forked]</a> by <a href="https://codepen.io/drprime01">Shoyombo Gabriel Ayomide</a>.</figcaption>
</figure>

<h3 id="try-portals-if-using-a-framework">Try Portals, If Using A Framework</h3>

<p>In frameworks like <a href="https://react.dev/reference/react-dom/createPortal">React</a> or <a href="https://www.digitalocean.com/community/tutorials/vuejs-portal-vue">Vue</a>, a Portal is a feature that lets you render a component outside its normal parent hierarchy in the DOM. Portals are like a teleportation device for your components. They let you render a component’s HTML anywhere in the document (typically right into <code>document.body</code>) while keeping it logically connected to its original parent for props, state, and events. This is perfect for escaping stacking context traps since the rendered output literally appears outside the problematic parent container.</p>

<pre><code class="language-javascript">ReactDOM.createPortal(
  &lt;ToolTip /&gt;,
  document.body
);
</code></pre>

<p>This ensures your dropdown content isn’t hidden behind its parent, even if the parent has <code>overflow: hidden</code> or a lower <code>z-index</code>.</p>

<p>In the “The Clipped Tooltip” scenario we looked at earlier, I used a Portal to rescue the tooltip from the <code>overflow: hidden</code> clip by placing it in the document body and positioning it above the trigger within the container.</p>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="myEqqEe"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [Scenario 3: The Clipped Tooltip (Solution) [forked]](https://codepen.io/smashingmag/pen/myEqqEe) by <a href="https://codepen.io/drprime01">Shoyombo Gabriel Ayomide</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/myEqqEe">Scenario 3: The Clipped Tooltip (Solution) [forked]</a> by <a href="https://codepen.io/drprime01">Shoyombo Gabriel Ayomide</a>.</figcaption>
</figure>

<div class="partners__lead-place"></div>

<h2 id="introducing-stacking-context-without-side-effects">Introducing Stacking Context Without Side Effects</h2>

<p>All the approaches explained in the previous section are aimed at “unstacking” elements from problematic stacking contexts, but there are some situations where you’ll actually need or want to create a stacking context.</p>

<p>Creating a new stacking context is easy, but all approaches come with a side effect. That is, except for using <a href="https://css-tricks.com/almanac/properties/i/isolation/"><code>isolation: isolate</code></a>. When applied to an element, the stacking context of that element’s children is determined relative to each child and within that context, rather than being influenced by elements outside of it. A classic example is assigning that element a negative value, such as <code>z-index: -1</code>.</p>

<p>Imagine you have a <code>.card</code> component. You want to add a decorative shape that sits behind the <code>.card</code>’s text, but on top of the card’s background. Without a stacking context on the card, <code>z-index: -1</code> sends the shape to the bottom of the root stacking context (the whole page). This makes it disappear behind the <code>.card</code>’s white background:</p>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="QwEOOEM"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [Negative z-index (problem) [forked]](https://codepen.io/smashingmag/pen/QwEOOEM) by <a href="https://codepen.io/drprime01">Shoyombo Gabriel Ayomide</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/QwEOOEM">Negative z-index (problem) [forked]</a> by <a href="https://codepen.io/drprime01">Shoyombo Gabriel Ayomide</a>.</figcaption>
</figure>

<p>To solve this, we declare <code>isolation: isolate</code> on the parent <code>.card</code>:</p>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="MYeOOeG"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [Negative z-index (solution) [forked]](https://codepen.io/smashingmag/pen/MYeOOeG) by <a href="https://codepen.io/drprime01">Shoyombo Gabriel Ayomide</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/MYeOOeG">Negative z-index (solution) [forked]</a> by <a href="https://codepen.io/drprime01">Shoyombo Gabriel Ayomide</a>.</figcaption>
</figure>

<p>Now, the <code>.card</code> element itself becomes a stacking context. When its child element &mdash; the decorative shape created on the <code>:before</code> pseudo-element &mdash; has <code>z-index: -1</code>, it goes to the very bottom of the parent’s stacking context. It sits perfectly behind the text and on top of the card’s background, as intended.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Remember: the next time your <code>z-index</code> seems out of control, it’s a trapped stacking context.</p>

<h3 id="references">References</h3>

<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Stacking_context">Stacking context</a> (MDN)</li>
<li><a href="https://web.dev/learn/css/z-index">Z-index and stacking contexts</a> (web.dev)</li>
<li>“<a href="https://www.freecodecamp.org/news/the-css-isolation-property/">How to Create a New Stacking Context with the Isolation Property in CSS</a>”, Natalie Pina</li>
<li>“<a href="https://www.joshwcomeau.com/css/stacking-contexts/">What The Heck, z-index??</a>”, Josh Comeau</li>
</ul>

<h3 id="further-reading-on-smashingmag">Further Reading On SmashingMag</h3>

<ul>
<li>“<a href="https://www.smashingmagazine.com/2021/02/css-z-index-large-projects/">Managing CSS Z-Index In Large Projects</a>”, Steven Frieson</li>
<li>“<a href="https://www.smashingmagazine.com/2024/09/sticky-headers-full-height-elements-tricky-combination/">Sticky Headers And Full-Height Elements: A Tricky Combination</a>”, Philip Braunen</li>
<li>“<a href="https://www.smashingmagazine.com/2019/04/z-index-component-based-web-application/">Managing Z-Index In A Component-Based Web Application</a>”, Pavel Pomerantsev</li>
<li>“<a href="https://www.smashingmagazine.com/2009/09/the-z-index-css-property-a-comprehensive-look/">The Z-Index CSS Property: A Comprehensive Look</a>”, Louis Lazaris</li>
</ul>

<div class="signature">
  <img src="https://www.smashingmagazine.com/images/logo/logo--red.png" alt="Smashing Editorial" width="35" height="46" loading="lazy" decoding="async" />
  <span>(gg, yk)</span>
</div>


              </article>
            </body>
          </html>
        ]]></content:encoded></item><item><author>Mat Marquis</author><title>JavaScript For Everyone: Iterators</title><link>https://www.smashingmagazine.com/2025/10/javascript-for-everyone-iterators/</link><pubDate>Mon, 27 Oct 2025 13:00:00 +0000</pubDate><guid>https://www.smashingmagazine.com/2025/10/javascript-for-everyone-iterators/</guid><description>Here is a lesson on Iterators: iterables implement the iterable iteration interface, and iterators implement the iterator iteration interface. Sounds confusing? Mat breaks it all down in the article.</description><content:encoded><![CDATA[
          <html>
            <head>
              <meta charset="utf-8">
              <link rel="canonical" href="https://www.smashingmagazine.com/2025/10/javascript-for-everyone-iterators/" />
              <title>JavaScript For Everyone: Iterators</title>
            </head>
            <body>
              <article>
                <header>
                  <h1>JavaScript For Everyone: Iterators</h1>
                  
                    
                    <address>Mat Marquis</address>
                  
                  <time datetime="2025-10-27T13:00:00&#43;00:00" class="op-published">2025-10-27T13:00:00+00:00</time>
                  <time datetime="2025-10-27T13:00:00&#43;00:00" class="op-modified">2026-05-12T16:11:33+00:00</time>
                </header>
                
                

<p>Hey, I’m Mat, but “Wilto” works too &mdash; I’m here to teach you JavaScript. Well, not <em>here</em>-here; technically, I’m over at <a href="https://piccalil.li/javascript-for-everyone">Piccalil.li’s <em>JavaScript for Everyone</em></a> course to teach you JavaScript. The following is an excerpt from the <strong>Iterables and Iterators</strong> module: the lesson on Iterators.</p>

<p>Iterators are one of JavaScript’s more linguistically confusing topics, sailing <em>easily</em> over what is already a pretty high bar. There are <em>iterables</em> &mdash; array, Set, Map, and string &mdash; all of which follow the <strong>iterable protocol</strong>. To follow said protocol, an object must implement the <strong>iterable interface</strong>. In practice, that means that the object needs to include a <code>[Symbol.iterator]()</code> method somewhere in its prototype chain. Iterable protocol is one of two <strong>iteration protocols</strong>. The other iteration protocol is the <strong>iterator protocol</strong>.</p>

<p>See what I mean about this being linguistically fraught? Iterables implement the iterable iteration interface, and iterators implement the iterator iteration interface! If you can say that five times fast, then you’ve pretty much got the gist of it; easy-peasy, right?</p>

<p>No, listen, by the time you reach the end of this lesson, I promise it won’t be half as confusing as it might sound, especially with the context you’ll have from the lessons that precede it.</p>

<p>An <strong>iterable</strong> object follows the iterable protocol, which just means that the object has a conventional method for making iterators. The elements that it contains can be looped over with <code>for</code>…<code>of</code>.</p>

<p>An <strong>iterator</strong> object follows the iterator protocol, and the elements it contains can be accessed <em>sequentially</em>, one at a time.</p>

<div data-audience="non-subscriber" data-remove="true" class="feature-panel-container">

<aside class="feature-panel" style="">
<div class="feature-panel-left-col">

<div class="feature-panel-description"><p>Meet <strong><a data-instant href="/printed-books/typescript-in-50-lessons/">“TypeScript in 50 Lessons”</a></strong>, our shiny new guide to TypeScript. With detailed <strong>code walkthroughs</strong>, hands-on examples and common gotchas. For developers who know enough <strong>JavaScript</strong> to be dangerous.</p>
<a data-instant href="/printed-books/typescript-in-50-lessons/" class="btn btn--green btn--large" style="">Jump to table of contents&nbsp;↬</a></div>
</div>
<div class="feature-panel-right-col"><a data-instant href="/printed-books/typescript-in-50-lessons/" class="feature-panel-image-link">
<div class="feature-panel-image"><picture><source type="image/avif" srcSet="https://archive.smashing.media/assets/344dbf88-fdf9-42bb-adb4-46f01eedd629/2732dfe9-e1ee-41c3-871a-6252aeda741c/typescript-panel.avif" />
<img
    loading="lazy"
    decoding="async"
    class="feature-panel-image-img"
    src="https://archive.smashing.media/assets/344dbf88-fdf9-42bb-adb4-46f01eedd629/c2f2c6d6-4e85-449a-99f5-58bd053bc846/typescript-shop-cover-opt.png"
    alt="Feature Panel"
    width="481"
    height="698"
/>
</picture>
</div>
</a>
</div>
</aside>
</div>

<p>To <em>reiterate</em> &mdash; a play on words for which I do not forgive myself, nor expect you to forgive me &mdash; an <strong>iterator</strong> object follows iterator protocol, and the elements it contains can be accessed <em>sequentially</em>, one at a time. Iterator protocol defines a standard way to produce a sequence of values, and optionally <code>return</code> a value once all possible values have been generated.</p>

<p>In order to follow the iterator protocol, an object has to &mdash; you guessed it &mdash; implement the <strong>iterator interface</strong>. In practice, that once again means that a certain method has to be available somewhere on the object&rsquo;s prototype chain. In this case, it’s the <code>next()</code> method that advances through the elements it contains, one at a time, and returns an object each time that method is called.</p>

<p>In order to meet the iterator interface criteria, the returned object must contain two properties with specific keys: one with the key <code>value</code>, representing the value of the current element, and one with the key <code>done</code>, a Boolean value that tells us if the iterator has advanced beyond the final element in the data structure. That’s not an awkward phrasing the editorial team let slip through: the value of that <code>done</code> property is <code>true</code> only when a call to <code>next()</code> results in an attempt to access an element <em>beyond</em> the final element in the iterator, not upon accessing the final element in the iterator. Again, a lot in print, but it’ll make more sense when you see it in action.</p>

<p>You’ve seen an example of a built-in iterator before, albeit briefly:</p>

<pre><code class="language-jsx">const theMap = new Map([ [ "aKey", "A value." ] ]);

console.log( theMap.keys() );
// Result: Map Iterator { constructor: Iterator() }
</code></pre>

<p>That’s right: while a Map object itself is an iterable, Map’s built-in methods <code>keys()</code>, <code>values()</code>, and <code>entries()</code> all return Iterator objects. You’ll also remember that I looped through those using <code>forEach</code> (a relatively recent addition to the language). Used that way, an iterator is indistinguishable from an iterable:</p>

<pre><code class="language-jsx">const theMap = new Map([ [ "key", "value " ] ]);

theMap.keys().forEach( thing =&gt; {
  console.log( thing );
});
// Result: key
</code></pre>

<p>All iterators are iterable; they all implement the iterable interface:</p>

<pre><code class="language-jsx">const theMap = new Map([ [ "key", "value " ] ]);

theMap.keys()[ Symbol.iterator ];
// Result: function Symbol.iterator()
</code></pre>

<p>And if you’re angry about the increasing blurriness of the line between iterators and iterables, wait until you get a load of this “top ten anime betrayals” video candidate: I’m going to demonstrate how to interact with an iterator by using an array.</p>

<p>“BOO,” you surely cry, having been so betrayed by one of your oldest and most indexed friends. “Array is an itera<em>ble</em>, not an itera<em>tor</em>!” You are both right to yell at me in general, and right about array in specific &mdash; an array <em>is</em> an iterable, not an iterator. In fact, while all iterators are iterable, none of the built-in iterables are iterators.</p>

<p>However, when you call that <code>[ Symbol.iterator ]()</code> method &mdash; the one that defines an object as an iterable &mdash; it returns an iterator object created from an iterable data structure:</p>

<pre><code class="language-jsx">const theIterable = [ true, false ];
const theIterator = theIterable[ Symbol.iterator ]();

theIterable;
// Result: Array [ true, false ]

theIterator;
// Result: Array Iterator { constructor: Iterator() }
</code></pre>

<p>The same goes for Set, Map, and &mdash; yes &mdash; even strings:</p>

<pre><code class="language-jsx">const theIterable = "A string."
const theIterator = theIterable[ Symbol.iterator ]();

theIterator;
// Result: String Iterator { constructor: Iterator() }
</code></pre>

<p>What we’re doing here manually &mdash; creating an iterator from an iterable using <code>%Symbol.iterator%</code> &mdash; is precisely how iterable objects work internally, and why they have to implement <code>%Symbol.iterator%</code> in order to <em>be</em> iterables. Any time you loop through an array, you’re actually looping through an iterator created from that Array. All built-in iterators <em>are</em> iterable. All built-in iterables can be used to <em>create</em> iterators.</p>

<p>Alternately &mdash; <em>preferably</em>, even, since it doesn’t require you to graze up against <code>%Symbol.iterator%</code> directly &mdash; you can use the built-in <code>Iterator.from()</code> method to create an iterator object from any iterable:</p>

<pre><code class="language-jsx">const theIterator = Iterator.from([ true, false ]);

theIterator;
// Result: Array Iterator { constructor: Iterator() }
</code></pre>

<p>You remember how I mentioned that an iterator has to provide a <code>next()</code> method (that returns a very specific Object)? Calling that <code>next()</code> method steps through the elements that the iterator contains one at a time, with each call returning an instance of that Object:</p>

<pre><code class="language-jsx">const theIterator = Iterator.from([ 1, 2, 3 ]);

theIterator.next();
// Result: Object { value: 1, done: false }

theIterator.next();
// Result: Object { value: 2, done: false }

theIterator.next();
// Result: Object { value: 3, done: false }

theIterator.next();
// Result: Object { value: undefined, done: true }
</code></pre>

<p>You can think of this as a more controlled form of traversal than the traditional “wind it up and watch it go” <code>for</code> loops you’re probably used to &mdash; a method of accessing elements one step at a time, as-needed. Granted, you don’t <em>have</em> to step through an iterator in this way, since they have their very own <code>Iterator.forEach</code> method, which works exactly like you would expect &mdash; to a point:</p>

<pre><code class="language-jsx">const theIterator = Iterator.from([ true, false ]);

theIterator.forEach( element =&gt; console.log( element ) );
/&#42; Result:
true
false
&#42;/
</code></pre>

<p>But there’s another big difference between iterables and iterators that we haven’t touched on yet, and for my money, it actually goes a long way toward making <em>linguistic</em> sense of the two. You might need to humor me for a little bit here, though.</p>

<p>See, an iterable object is an object that is iterable. No, listen, stay with me: you can iterate over an Array, and when you’re done doing so, you can still iterate over that Array. It is, by definition, an object that can be iterated over; it is the essential nature of an iterable to be iterable:</p>

<pre><code class="language-jsx">const theIterable = [ 1, 2 ];

theIterable.forEach( el =&gt; {
  console.log( el );
});
/&#42; Result:
1
2
&#42;/

theIterable.forEach( el =&gt; {
  console.log( el );
});
/&#42; Result:
1
2
&#42;/
</code></pre>

<p>In a way, an iterator object represents the singular <em>act</em> of iteration. Internal to an iterable, it is the mechanism by which the iterable is iterated over, each time that iteration is performed. As a stand-alone iterator object &mdash; whether you step through it using the <code>next</code> method or loop over its elements using <code>forEach</code> &mdash; once iterated over, that iterator is <em>past tense</em>; it is <em>iterated</em>. Because they maintain an internal state, the essential nature of an iterator is to be iterated over, singular:</p>

<pre><code class="language-jsx">const theIterator = Iterator.from([ 1, 2 ]);

theIterator.next();
// Result: Object { value: 1, done: false }

theIterator.next();
// Result: Object { value: 2, done: false }

theIterator.next();
// Result: Object { value: undefined, done: true }

theIterator.forEach( el =&gt; console.log( el ) );
// Result: undefined
</code></pre>

<p>That makes for neat work when you&rsquo;re using the Iterator constructor’s built-in methods to, say, filter or extract part of an Iterator object:</p>

<div class="break-out">
<pre><code class="language-jsx">const theIterator = Iterator.from([ "First", "Second", "Third" ]);

// Take the first two values from `theIterator`:
theIterator.take( 2 ).forEach( el =&gt; {
  console.log( el );
});
/&#42; Result:
"First"
"Second"
&#42;/

// theIterator now only contains anything left over after the above operation is complete:
theIterator.next();
// Result: Object { value: "Third", done: false }
</code></pre>
</div>

<p>Once you reach the end of an iterator, the act of iterating over it is complete. Iterated. Past-tense.</p>

<p>And so too is your time in this lesson, you might be relieved to hear. I know this was kind of a rough one, but the good news is: this course is iterable, not an iterator. This step in your iteration through it &mdash; this lesson &mdash; may be over, but the essential nature of this course is that you can iterate through it again. Don’t worry about committing all of this to memory right now &mdash; you can come back and revisit this lesson anytime.</p>

<div class="partners__lead-place"></div>

<h2 id="conclusion">Conclusion</h2>

<p>I stand by what I wrote there, unsurprising as that probably is: this lesson is a tricky one, but listen, <em>you got this</em>. <a href="https://piccalil.li/javascript-for-everyone">JavaScript for Everyone</a> is designed to take you inside JavaScript’s head. Once you’ve started seeing how the gears mesh &mdash; seen the fingerprints left behind by the people who built the language, and the good, bad, and sometimes baffling decisions that went into that &mdash; no <em>itera-</em>, whether <em>-ble</em> or <em>-tor</em> will be able to stand in your way.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://piccalil.li/javascript-for-everyone">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="450"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/javascript-for-everyone-iterators/1-javascript-for-everyone.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/javascript-for-everyone-iterators/1-javascript-for-everyone.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/javascript-for-everyone-iterators/1-javascript-for-everyone.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/javascript-for-everyone-iterators/1-javascript-for-everyone.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/javascript-for-everyone-iterators/1-javascript-for-everyone.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/javascript-for-everyone-iterators/1-javascript-for-everyone.png"
			
			sizes="100vw"
			alt="Javascript for everyone course announcement"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      <a href='https://piccalil.li/javascript-for-everyone'>JavaScript for Everyone</a> is now available and the launch price runs until midnight, October 28. Save £60 off the full price of £249 and get it for £189! (<a href='https://files.smashing.media/articles/javascript-for-everyone-iterators/1-javascript-for-everyone.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>My goal is to teach you the <em>deep magic</em> &mdash; the <em>how</em> and the <em>why</em> of JavaScript, using the syntaxes you’re most likely to encounter in your day-to-day work, at your pace and on your terms. If you’re new to the language, you’ll walk away from this course with a foundational understanding of JavaScript worth hundreds of hours of trial-and-error. If you’re a junior developer, you’ll finish this course with a depth of knowledge to rival any senior.</p>

<p>I hope to see you there.</p>

<div class="signature">
  <img src="https://www.smashingmagazine.com/images/logo/logo--red.png" alt="Smashing Editorial" width="35" height="46" loading="lazy" decoding="async" />
  <span>(gg, yk)</span>
</div>


              </article>
            </body>
          </html>
        ]]></content:encoded></item><item><author>Victor Ayomipo</author><title>Integrating CSS Cascade Layers To An Existing Project</title><link>https://www.smashingmagazine.com/2025/09/integrating-css-cascade-layers-existing-project/</link><pubDate>Wed, 10 Sep 2025 10:00:00 +0000</pubDate><guid>https://www.smashingmagazine.com/2025/09/integrating-css-cascade-layers-existing-project/</guid><description>The idea behind this is to share a full, unfiltered look at integrating CSS Cascade Layers into an existing legacy codebase. In practice, it’s about refactoring existing CSS to use cascade layers without breaking anything.</description><content:encoded><![CDATA[
          <html>
            <head>
              <meta charset="utf-8">
              <link rel="canonical" href="https://www.smashingmagazine.com/2025/09/integrating-css-cascade-layers-existing-project/" />
              <title>Integrating CSS Cascade Layers To An Existing Project</title>
            </head>
            <body>
              <article>
                <header>
                  <h1>Integrating CSS Cascade Layers To An Existing Project</h1>
                  
                    
                    <address>Victor Ayomipo</address>
                  
                  <time datetime="2025-09-10T10:00:00&#43;00:00" class="op-published">2025-09-10T10:00:00+00:00</time>
                  <time datetime="2025-09-10T10:00:00&#43;00:00" class="op-modified">2026-05-12T16:11:33+00:00</time>
                </header>
                
                

<p>You can always get a fantastic overview of things in Stephenie Eckles’ article, “<a href="https://www.smashingmagazine.com/2022/01/introduction-css-cascade-layers/">Getting Started With CSS Cascade Layers</a>”. But let’s talk about the experience of integrating cascade layers into real-world code, the good, the bad, and the spaghetti.</p>

<p>I could have created a sample project for a classic walkthrough, but nah, that’s not how things work in the real world. I want to get our hands dirty, like inheriting code with styles that work and no one knows why.</p>

<p>Finding projects without cascade layers was easy. The tricky part was finding one that was messy enough to have specificity and organisation issues, but broad enough to illustrate different parts of cascade layers integration.</p>

<p>Ladies and gentlemen, I present you with this <a href="https://github.com/Drix10/discord-bot-web">Discord bot website</a> by <a href="https://github.com/Drix10">Drishtant Ghosh</a>. I’m deeply grateful to Drishtant for allowing me to use his work as an example. This project is a typical landing page with a navigation bar, a hero section, a few buttons, and a mobile menu.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/integrating-css-cascade-layers-existing-project/1-discord-bot-landing-page.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/integrating-css-cascade-layers-existing-project/1-discord-bot-landing-page.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/integrating-css-cascade-layers-existing-project/1-discord-bot-landing-page.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/integrating-css-cascade-layers-existing-project/1-discord-bot-landing-page.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/integrating-css-cascade-layers-existing-project/1-discord-bot-landing-page.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/integrating-css-cascade-layers-existing-project/1-discord-bot-landing-page.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/integrating-css-cascade-layers-existing-project/1-discord-bot-landing-page.png"
			
			sizes="100vw"
			alt="Discord Bot landing page, including a circular logo centered above a heading, text blub, then a row of three buttons."
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/integrating-css-cascade-layers-existing-project/1-discord-bot-landing-page.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>You see how it looks perfect on the outside. Things get interesting, however, when we look at the CSS styles under the hood.</p>

<div data-audience="non-subscriber" data-remove="true" class="feature-panel-container">

<aside class="feature-panel" style="">
<div class="feature-panel-left-col">

<div class="feature-panel-description"><p>Meet <strong><a data-instant href="https://www.smashingconf.com/online-workshops/">Smashing Workshops</a></strong> on <strong>front-end, design &amp; UX</strong>, with practical takeaways, live sessions, <strong>video recordings</strong> and a friendly Q&amp;A. With Brad Frost, Stéph Walter and <a href="https://smashingconf.com/online-workshops/workshops">so many others</a>.</p>
<a data-instant href="smashing-workshops" class="btn btn--green btn--large" style="">Jump to the workshops&nbsp;↬</a></div>
</div>
<div class="feature-panel-right-col"><a data-instant href="smashing-workshops" class="feature-panel-image-link">
<div class="feature-panel-image">
<img
    loading="lazy"
    decoding="async"
    class="feature-panel-image-img"
    src="/images/smashing-cat/cat-scubadiving-panel.svg"
    alt="Feature Panel"
    width="257"
    height="355"
/>

</div>
</a>
</div>
</aside>
</div>

<h2 id="understanding-the-project">Understanding The Project</h2>

<p>Before we start throwing <code>@layers</code> around, let’s get a firm understanding of what we’re working with. I <a href="https://codepen.io/vayospot/pen/bNdoYdP">cloned</a> the GitHub repo, and since our focus is working with CSS Cascade Layers, I’ll focus only on the main page, which consists of three files: <code>index.html</code>, <code>index.css</code>, and <code>index.js</code>.</p>

<p><strong>Note</strong>: <em>I didn’t include other pages of this project as it’d make this tutorial too verbose. However, you can refactor the other pages as an experiment.</em></p>

<p>The <code>index.css</code> file is over 450 lines of code, and skimming through it, I can see some red flags right off the bat:</p>

<ul>
<li>There’s a lot of code repetition with the same selectors pointing to the same HTML element.</li>
<li>There are quite a few <code>#id</code> selectors, which one might argue shouldn’t be used in CSS (and I am one of those people).</li>
<li><code>#botLogo</code> is defined twice and over 70 lines apart.</li>
<li>The <code>!important</code> keyword is used liberally throughout the code.</li>
</ul>

<p>And yet the site works. There is nothing “technically” wrong here, which is another reason CSS is a big, beautiful monster &mdash; errors are silent!</p>

<h2 id="planning-the-layer-structure">Planning The Layer Structure</h2>

<p>Now, some might be thinking, <em>“Can’t we simply move all of the styles into a single layer, like <code>@layer legacy</code> and call it a day?”</em></p>

<p>You could… but I don’t think you should.</p>

<p>Think about it: If more layers are added after the <code>legacy</code> layer, they <em>should</em> override the styles contained in the <code>legacy</code> layer because the specificity of layers is organized by priority, where the layers declared later carry higher priority.</p>

<pre><code class="language-css">/&#42; new is more specific &#42;/
@layer legacy, new;

/&#42; legacy is more specific &#42;/
@layer new, legacy;
</code></pre>

<p>That said, we must remember that the site’s existing styles make liberal use of the <code>!important</code> keyword. And when that happens, the order of cascade layers gets reversed. So, even though the layers are outlined like this:</p>

<pre><code class="language-css">@layer legacy, new;
</code></pre>

<p>…any styles with an <code>!important</code> declaration suddenly shake things up. In this case, the priority order becomes:</p>

<ol>
<li><code>!important</code> styles in the <code>legacy</code> layer (most powerful),</li>
<li><code>!important</code> styles in the <code>new</code> layer,</li>
<li>Normal styles in the <code>new</code> layer,</li>
<li>Normal styles in the <code>legacy</code> layer (least powerful).</li>
</ol>

<p>I just wanted to clear that part up. Let’s continue.</p>

<p>We know that cascade layers handle specificity by creating an explicit order where each layer has a clear responsibility, and later layers always win.</p>

<p>So, I decided to split things up into five distinct layers:</p>

<ul>
<li><strong><code>reset</code></strong>: Browser default resets like <code>box-sizing</code>, margins, and paddings.</li>
<li><strong><code>base</code></strong>: Default styles of HTML elements, like <code>body</code>, <code>h1</code>, <code>p</code>, <code>a</code>, etc., including default typography and colours.</li>
<li><strong><code>layout</code></strong>: Major page structure stuff for controlling how elements are positioned.</li>
<li><strong><code>components</code></strong>: Reusable UI segments, like buttons, cards, and menus.</li>
<li><strong><code>utilities</code></strong>: Single helper modifiers that do just one thing and do it well.</li>
</ul>

<p>This is merely how I like to break things out and organize styles. Zell Liew, for example, <a href="https://css-tricks.com/composition-in-css/">has a different set of four buckets</a> that could be defined as layers.</p>

<p>There’s also the concept of dividing things up even further into <strong>sublayers</strong>:</p>

<pre><code class="language-css">@layer components {
  /&#42; sub-layers &#42;/
  @layer buttons, cards, menus;
}

/&#42; or this: &#42;/
@layer components.buttons, components.cards, components.menus;
</code></pre>

<p>That might come in handy, but I also don’t want to overly abstract things. That might be a better strategy for a project that’s scoped to a well-defined design system.</p>

<p>Another thing we could leverage is <strong>unlayered styles</strong> and the fact that any styles not contained in a cascade layer get the highest priority:</p>

<pre><code class="language-css">@layer legacy { a { color: red !important; } }
@layer reset { a { color: orange !important; } }
@layer base { a { color: yellow !important; } }

/&#42; unlayered &#42;/
a { color: green !important; } /&#42; highest priority &#42;/
</code></pre>

<p>But I like the idea of keeping all styles organized in explicit layers because it keeps things <strong>modular</strong> and <strong>maintainable</strong>, at least in this context.</p>

<p>Let’s move on to adding cascade layers to this project.</p>

<div class="partners__lead-place"></div>

<h2 id="integrating-cascade-layers">Integrating Cascade Layers</h2>

<p>We need to define the layer order at the top of the file:</p>

<pre><code class="language-css">@layer reset, base, layout, components, utilities;
</code></pre>

<p>This makes it easy to tell which layer takes precedence over which (they get more priority from left to right), and now we can think in terms of layer responsibility instead of selector weight. Moving forward, I’ll proceed through the stylesheet from top to bottom.</p>

<p>First, I noticed that the <a href="https://fonts.google.com/specimen/Poppins?query=poppins">Poppins font</a> was imported in both the HTML and CSS files, so I removed the CSS import and left the one in <code>index.html</code>, as that’s generally recommended for quickly loading fonts.</p>

<p>Next is the universal selector (<code>*</code>) styles, which include <a href="https://css-tricks.com/box-sizing/">classic reset styles</a> that are perfect for <code>@layer reset</code>:</p>

<pre><code class="language-css">@layer reset {
  &#42; {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }
}
</code></pre>

<p>With that out of the way, the <code>body</code> selector is next. I’m putting this into <code>@layer base</code> because it contains core styles for the project, like backgrounds and fonts:</p>

<div class="break-out">
<pre><code class="language-css">@layer base {
  body {
    background-image: url("bg.svg"); /&#42; Renamed to bg.svg for clarity &#42;/
    font-family: "Poppins", sans-serif;
    /&#42; ... other styles &#42;/
  }
}
</code></pre>
</div>

<p>The way I’m tackling this is that styles in the <code>base</code> layer should generally affect the whole document. So far, no page breaks or anything.</p>

<h3 id="swapping-ids-for-classes">Swapping IDs For Classes</h3>

<p>Following the <code>body</code> element selector is the page loader, which is defined as an ID selector, <code>#loader</code>.</p>

<blockquote>I’m a firm believer in using class selectors over ID selectors as much as possible. It keeps specificity low by default, which prevents specificity battles and <a href="https://css-tricks.com/the-difference-between-id-and-class/">makes the code a lot more maintainable</a>.</blockquote>

<p>So, I went into the <code>index.html</code> file and refactored elements with <code>id=&quot;loader&quot;</code> to <code>class=&quot;loader&quot;</code>. In the process, I saw another element with <code>id=&quot;page&quot;</code> and changed that at the same time.</p>

<p>While still in the <code>index.html</code> file, I noticed a few <code>div</code> elements missing closing tags. It is astounding how permissive browsers are with that. Anyways, I cleaned those up and moved the <code>&lt;script&gt;</code> tag out of the <code>.heading</code> element to be a direct child of <code>body</code>. Let’s not make it any tougher to load our scripts.</p>

<p>Now that we’ve levelled the specificity playing field by moving IDs to classes, we can drop them into the <code>components</code> layer since a loader is indeed a reusable component:</p>

<pre><code class="language-css">@layer components {
  .loader {
    width: 100%;
    height: 100vh;
    /&#42; ... &#42;/
  }
  .loader .loading {
    /&#42; ... &#42;/
  }
  .loader .loading span {
    /&#42; ... &#42;/
  }
  .loader .loading span:before {
    /&#42; ... &#42;/
  }
}
</code></pre>

<h3 id="animations">Animations</h3>

<p>Next are keyframes, and this was a bit tricky, but I eventually chose to isolate animations in their own new fifth layer and updated the layer order to include it:</p>

<div class="break-out">
<pre><code class="language-css">@layer reset, base, layout, components, utilities, animations;
</code></pre>
</div>

<p>But why place <code>animations</code> as the last layer? Because animations are generally the last to run and shouldn’t be affected by style conflicts.</p>

<p>I searched the project’s styles for <code>@keyframes</code> and dumped them into the new layer:</p>

<pre><code class="language-css">@layer animations {
  @keyframes loading {
    /&#42; ... &#42;/
  }
  @keyframes loading2 {
    /&#42; ... &#42;/
  }
  @keyframes pageShow {
    /&#42; ... &#42;/
  }
}
</code></pre>

<p>This gives a clear distinction of static styles from dynamic ones while also enforcing reusability.</p>

<h3 id="layouts">Layouts</h3>

<p>The <code>#page</code> selector also has the same issue as <code>#id</code>, and since we fixed it in the HTML earlier, we can modify it to <code>.page</code> and drop it in the <code>layout</code> layer, as its main purpose is to control the initial visibility of the content:</p>

<pre><code class="language-css">@layer layout {
  .page {
    display: none;
  }
}
</code></pre>

<h3 id="custom-scrollbars">Custom Scrollbars</h3>

<p>Where do we put these? Scrollbars are global elements that persist across the site. This might be a gray area, but I’d say it fits perfectly in <code>@layer base</code> since it’s a global, default feature.</p>

<pre><code class="language-css">@layer base {
  /&#42; ... &#42;/
  ::-webkit-scrollbar {
    width: 8px;
  }
  ::-webkit-scrollbar-track {
    background: &#35;0e0e0f;
  }
  ::-webkit-scrollbar-thumb {
    background: &#35;5865f2;
    border-radius: 100px;
  }
  ::-webkit-scrollbar-thumb:hover {
    background: &#35;202225;
  }
}
</code></pre>

<p>I also removed the <code>!important</code> keywords as I came across them.</p>

<h3 id="navigation">Navigation</h3>

<p>The <code>nav</code> element is pretty straightforward, as it is the main structure container that defines the position and dimensions of the navigation bar. It should definitely go in the <code>layout</code> layer:</p>

<pre><code class="language-css">@layer layout {
  /&#42; ... &#42;/
  nav {
    display: flex;
    height: 55px;
    width: 100%;
    padding: 0 50px; /&#42; Consistent horizontal padding &#42;/
    /&#42; ... &#42;/
  }
}
</code></pre>

<h3 id="logo">Logo</h3>

<p>We have three style blocks that are tied to the logo: <code>nav .logo</code>, <code>.logo img</code>, and <code>#botLogo</code>. These names are redundant and could benefit from inheritance component reusability.</p>

<p>Here’s how I’m approaching it:</p>

<ol>
<li>The <code>nav .logo</code> is overly specific since the logo can be reused in other places. I dropped the <code>nav</code> so that the selector is just <code>.logo</code>. There was also an <code>!important</code> keyword in there, so I removed it.</li>
<li>I updated <code>.logo</code> to be a Flexbox container to help position <code>.logo img</code>, which was previously set with less flexible absolute positioning.</li>
<li>The <code>#botLogo</code> ID is declared twice, so I merged the two rulesets into one and lowered its specificity by making it a <code>.botLogo</code> class. And, of course, I updated the HTML to replace the ID with the class.</li>
<li>The <code>.logo img</code> selector becomes <code>.botLogo</code>, making it the base class for styling all instances of the logo.</li>
</ol>

<p>Now, we’re left with this:</p>

<pre><code class="language-css">/&#42; initially .logo img &#42;/
.botLogo {
  border-radius: 50%;
  height: 40px;
  border: 2px solid &#35;5865f2;
}

/&#42; initially &#35;botLogo &#42;/
.botLogo {
  border-radius: 50%;
  width: 180px;
  /&#42; ... &#42;/
}
</code></pre>

<p>The difference is that one is used in the navigation and the other in the hero section heading. We can transform the second <code>.botLogo</code> by slightly increasing the specificity with a <code>.heading .botLogo</code> selector. We may as well clean up any duplicated styles as we go.</p>

<p>Let’s place the entire code in the <code>components</code> layer as we’ve successfully turned the logo into a reusable component:</p>

<div class="break-out">
<pre><code class="language-css">@layer components {
  /&#42; ... &#42;/
  .logo {
    font-size: 30px;
    font-weight: bold;
    color: &#35;fff;
    display: flex;
    align-items: center;
    gap: 10px;
  }
  .botLogo {
    aspect-ratio: 1; /&#42; maintains square dimensions with width &#42;/
    border-radius: 50%;
    width: 40px;
    border: 2px solid &#35;5865f2;
  }
  .heading .botLogo {
    width: 180px;
    height: 180px;
    background-color: &#35;5865f2;
    box-shadow: 0px 0px 8px 2px rgba(88, 101, 242, 0.5);
    /&#42; ... &#42;/
  }
}
</code></pre>
</div>

<p>This was a bit of work! But now the logo is properly set up as a component that fits perfectly in the new layer architecture.</p>

<div class="partners__lead-place"></div>

<h3 id="navigation-list">Navigation List</h3>

<p>This is a typical navigation pattern. Take an unordered list (<code>&lt;ul&gt;</code>) and turn it into a flexible container that displays all of the list items horizontally on the same row (with wrapping allowed). It’s a type of navigation that can be reused, which belongs in the <code>components</code> layer. But there’s a little refactoring to do before we add it.</p>

<p>There’s already a <code>.mainMenu</code> class, so let’s lean into that. We’ll swap out any <code>nav ul</code> selectors with that class. Again, it keeps specificity low while making it clearer what that element does.</p>

<pre><code class="language-css">@layer components {
  /&#42; ... &#42;/
  .mainMenu {
    display: flex;
    flex-wrap: wrap;
    list-style: none;
  }
  .mainMenu li {
    margin: 0 4px;
  }
  .mainMenu li a {
    color: &#35;fff;
    text-decoration: none;
    font-size: 16px;
    /&#42; ... &#42;/
  }
  .mainMenu li a:where(.active, .hover) {
    color: &#35;fff;
    background: &#35;1d1e21;
  }
  .mainMenu li a.active:hover {
    background-color: &#35;5865f2;
  }
}
</code></pre>

<p>There are also two buttons in the code that are used to toggle the navigation between “open” and “closed” states when the navigation is collapsed on smaller screens. It’s tied specifically to the <code>.mainMenu</code> component, so we’ll keep everything together in the <code>components</code> layer. We can combine and simplify the selectors in the process for cleaner, more readable styles:</p>

<pre><code class="language-css">@layer components {
  /&#42; ... &#42;/
  nav:is(.openMenu, .closeMenu) {
    font-size: 25px;
    display: none;
    cursor: pointer;
    color: &#35;fff;
  }
}
</code></pre>

<p>I also noticed that several other selectors in the CSS were not used anywhere in the HTML. So, I removed those styles to keep things trim. There are <a href="https://css-tricks.com/how-do-you-remove-unused-css-from-a-site/">automated ways to go about this</a>, too.</p>

<h3 id="media-queries">Media Queries</h3>

<p>Should media queries have a dedicated layer (<code>@layer responsive</code>), or should they be in the same layer as their target elements? I really struggled with that question while refactoring the styles for this project. I did some research and testing, and my verdict is the latter, that <strong>media queries ought to be in the same layer as the elements they affect</strong>.</p>

<p>My reasoning is that keeping them together:</p>

<ul>
<li>Maintains responsive styles with their base element styles,</li>
<li>Makes overrides predictable, and</li>
<li>Flows well with component-based architecture common in modern web development.</li>
</ul>

<p>However, it also means <strong>responsive logic</strong> is scattered across layers. But it beats the one with a gap between the layer where elements are styled and the layer where their responsive behaviors are managed. That’s a deal-breaker for me because it’s way too easy to update styles in one layer and forget to update their corresponding responsive style in the responsive layer.</p>

<p>The other big point is that media queries in the same layer have <strong>the same priority</strong> as their elements. This is consistent with my overall goal of keeping the CSS Cascade simple and predictable, free of style conflicts.</p>

<p>Plus, the <a href="https://css-tricks.com/tag/nesting/">CSS nesting syntax</a> makes the relationship between media queries and elements super clear. Here’s an abbreviated example of how things look when we nest media queries in the <code>components</code> layer:</p>

<pre><code class="language-css">@layer components {
  .mainMenu {
    display: flex;
    flex-wrap: wrap;
    list-style: none;
  }
  @media (max-width: 900px) {
    .mainMenu {
      width: 100%;
      text-align: center;
      height: 100vh;
      display: none;
    }
  }
}
</code></pre>

<p>This also allows me to nest a component’s child element styles (e.g., <code>nav .openMenu</code> and <code>nav .closeMenu</code>).</p>

<pre><code class="language-css">@layer components {
  nav {
    &.openMenu {
      display: none;
      
      @media (max-width: 900px) {
        &.openMenu {
          display: block;
        }
      }
    }
  }
}
</code></pre>

<h3 id="typography-buttons">Typography &amp; Buttons</h3>

<p>The <code>.title</code> and <code>.subtitle</code> can be seen as typography components, so they and their responsive associates go into &mdash; you guessed it &mdash; the <code>components</code> layer:</p>

<pre><code class="language-css">@layer components {
  .title {
    font-size: 40px;
    font-weight: 700;
    /&#42; etc. &#42;/
  }
  .subtitle {
    color: rgba(255, 255, 255, 0.75);
    font-size: 15px;
    /&#42; etc.. &#42;/
  }
  @media (max-width: 420px) {
    .title {
      font-size: 30px;
    }
    .subtitle {
      font-size: 12px;
    }
  }
}
</code></pre>

<p>What about buttons? Like many website’s this one has a class, <code>.btn</code>, for that component, so we can chuck those in there as well:</p>

<pre><code class="language-css">@layer components {
  .btn {
    color: &#35;fff;
    background-color: #1d1e21;
    font-size: 18px;
    /&#42; etc. &#42;/
  }
  .btn-primary {
    background-color: &#35;5865f2;
  }
  .btn-secondary {
    transition: all 0.3s ease-in-out;
  }
  .btn-primary:hover {
    background-color: &#35;5865f2;
    box-shadow: 0px 0px 8px 2px rgba(88, 101, 242, 0.5);
    /&#42; etc. &#42;/
  }
  .btn-secondary:hover {
    background-color: &#35;1d1e21;
    background-color: rgba(88, 101, 242, 0.7);
  }
  @media (max-width: 420px) {
    .btn {
      font-size: 14px;
      margin: 2px;
      padding: 8px 13px;
    }
  }
  @media (max-width: 335px) {
    .btn {
      display: flex;
      flex-direction: column;
    }
  }
}
</code></pre>

<h3 id="the-final-layer">The Final Layer</h3>

<p>We haven’t touched the <code>utilities</code> layer yet! I’ve reserved this layer for helper classes that are designed for specific purposes, like hiding content &mdash; or, in this case, there’s a <code>.noselect</code> class that fits right in. It has a single reusable purpose: to disable selection on an element.</p>

<p>So, that’s going to be the only style rule in our <code>utilities</code> layer:</p>

<pre><code class="language-css">@layer utilities {
  .noselect {
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    -khtml-user-select: none;
    -webkit-user-drag: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
  }
}
</code></pre>
 

<p>And that’s it! We’ve completely refactored the CSS of a real-world project to use CSS Cascade Layers. You can compare <a href="https://codepen.io/vayospot/pen/bNdoYdP">where we started</a> with the <a href="https://codepen.io/vayospot/pen/XJbeVdB">final code</a>.</p>

<h2 id="it-wasn-t-all-easy">It Wasn’t All Easy</h2>

<p>That’s not to say that working with Cascade Layers was challenging, but there were some sticky points in the process that forced me to pause and carefully think through what I was doing.</p>

<p>I kept some notes as I worked:</p>

<ul>
<li><strong>It’s tough to determine where to start with an existing project.</strong><br />
However, by defining the layers first and setting their priority levels, I had a framework for deciding how and where to move specific styles, even though I was not totally familiar with the existing CSS. That helped me avoid situations where I might second-guess myself or define extra, unnecessary layers.</li>
<li><strong>Browser support is still a thing!</strong><br />
I mean, Cascade Layers enjoy 94% support coverage as I’m writing this, but you might be one of those sites that needs to accommodate legacy browsers that are unable to support layered styles.</li>
<li><strong>It wasn’t clear where media queries fit into the process.</strong><br />
Media queries put me on the spot to find where they work best: nested in the same layers as their selectors, or in a completely separate layer? I went with the former, as you know.</li>
<li><strong>The <code>!important</code> keyword is a juggling act.</strong><br />
They invert the entire layering priority system, and this project was littered with instances. Once you start chipping away at those, the existing CSS architecture erodes and requires a balance between refactoring the code and fixing what’s already there to know exactly how styles cascade.</li>
</ul>

<blockquote class="pull-quote">
  <p>
    <a class="pull-quote__link" aria-label="Share on Twitter" href="https://twitter.com/share?text=%0aOverall,%20refactoring%20a%20codebase%20for%20CSS%20Cascade%20Layers%20is%20a%20bit%20daunting%20at%20first%20glance.%20The%20important%20thing,%20though,%20is%20to%20acknowledge%20that%20it%20isn%e2%80%99t%20really%20the%20layers%20that%20complicate%20things,%20but%20the%20existing%20codebase.%0a&url=https://smashingmagazine.com%2f2025%2f09%2fintegrating-css-cascade-layers-existing-project%2f">
      
Overall, refactoring a codebase for CSS Cascade Layers is a bit daunting at first glance. The important thing, though, is to acknowledge that it isn’t really the layers that complicate things, but the existing codebase.

    </a>
  </p>
  <div class="pull-quote__quotation">
    <div class="pull-quote__bg">
      <span class="pull-quote__symbol">“</span></div>
  </div>
</blockquote>

<p>It’s tough to completely overhaul someone’s existing approach for a new one, even if the new approach is elegant.</p>

<h2 id="where-cascade-layers-helped-and-didn-t">Where Cascade Layers Helped (And Didn’t)</h2>

<p>Establishing layers improved the code, no doubt. I’m sure there are some <strong>performance benchmarks</strong> in there since we were able to remove unused and conflicting styles, but the real win is in <strong>a more maintainable set of styles</strong>. It’s easier to find what you need, know what specific style rules are doing, and where to insert new styles moving forward.</p>

<p>At the same time, I wouldn’t say that Cascade Layers are a silver bullet solution. Remember, CSS is intrinsically tied to the HTML structure it queries. If the HTML you’re working with is unstructured and suffers from <code>div</code>-itus, then you can safely bet that the effort to untangle that mess is higher and involves rewriting markup at the same time.</p>

<blockquote class="pull-quote">
  <p>
    <a class="pull-quote__link" aria-label="Share on Twitter" href="https://twitter.com/share?text=%0aRefactoring%20CSS%20for%20cascade%20layers%20is%20most%20certainly%20worth%20the%20maintenance%20enhancements%20alone.%0a&url=https://smashingmagazine.com%2f2025%2f09%2fintegrating-css-cascade-layers-existing-project%2f">
      
Refactoring CSS for cascade layers is most certainly worth the maintenance enhancements alone.

    </a>
  </p>
  <div class="pull-quote__quotation">
    <div class="pull-quote__bg">
      <span class="pull-quote__symbol">“</span></div>
  </div>
</blockquote>

<p>It may be “easier” to start from scratch and define layers as you work from the ground up because there’s less inherited overhead and technical debt to sort through. But if you have to start from an existing codebase, you might need to de-tangle the complexity of your styles first to determine exactly how much refactoring you’re looking at.</p>

<div class="signature">
  <img src="https://www.smashingmagazine.com/images/logo/logo--red.png" alt="Smashing Editorial" width="35" height="46" loading="lazy" decoding="async" />
  <span>(gg, yk)</span>
</div>


              </article>
            </body>
          </html>
        ]]></content:encoded></item><item><author>Fuqiao Xue</author><title>The Power Of The &lt;code>Intl&lt;/code> API: A Definitive Guide To Browser-Native Internationalization</title><link>https://www.smashingmagazine.com/2025/08/power-intl-api-guide-browser-native-internationalization/</link><pubDate>Fri, 08 Aug 2025 10:00:00 +0000</pubDate><guid>https://www.smashingmagazine.com/2025/08/power-intl-api-guide-browser-native-internationalization/</guid><description>Internationalization isn’t just translation. It’s about formatting dates, pluralizing words, sorting names, and more, all according to specific locales. Instead of relying on heavy third-party libraries, modern JavaScript offers the Intl API &amp;mdash; a powerful, native way to handle i18n. A quiet reminder that the web truly is worldwide.</description><content:encoded><![CDATA[
          <html>
            <head>
              <meta charset="utf-8">
              <link rel="canonical" href="https://www.smashingmagazine.com/2025/08/power-intl-api-guide-browser-native-internationalization/" />
              <title>The Power Of The &lt;code&gt;Intl&lt;/code&gt; API: A Definitive Guide To Browser-Native Internationalization</title>
            </head>
            <body>
              <article>
                <header>
                  <h1>The Power Of The &lt;code&gt;Intl&lt;/code&gt; API: A Definitive Guide To Browser-Native Internationalization</h1>
                  
                    
                    <address>Fuqiao Xue</address>
                  
                  <time datetime="2025-08-08T10:00:00&#43;00:00" class="op-published">2025-08-08T10:00:00+00:00</time>
                  <time datetime="2025-08-08T10:00:00&#43;00:00" class="op-modified">2026-05-12T16:11:33+00:00</time>
                </header>
                
                

<p>It’s a common misconception that internationalization (i18n) is simply about translating text. While crucial, translation is merely one facet. One of the complexities lies in <strong>adapting information for diverse cultural expectations</strong>: How do you display a date in Japan versus Germany? What’s the correct way to pluralize an item in Arabic versus English? How do you sort a list of names in various languages?</p>

<p>Many developers have relied on weighty third-party libraries or, worse, custom-built formatting functions to tackle these challenges. These solutions, while functional, often come with significant overhead: increased bundle size, potential performance bottlenecks, and the constant struggle to keep up with evolving linguistic rules and locale data.</p>

<p>Enter the <strong>ECMAScript Internationalization API</strong>, more commonly known as the <code>Intl</code> object. This silent powerhouse, built directly into modern JavaScript environments, is an often-underestimated, yet incredibly <strong>potent, native, performant, and standards-compliant solution</strong> for handling data internationalization. It’s a testament to the web’s commitment to being <em>worldwide</em>, providing a unified and efficient way to format numbers, dates, lists, and more, according to specific locales.</p>

<h2 id="intl-and-locales-more-than-just-language-codes"><code>Intl</code> And Locales: More Than Just Language Codes</h2>

<p>At the heart of <code>Intl</code> lies the concept of a <strong>locale</strong>. A locale is far more than just a two-letter language code (like <code>en</code> for English or <code>es</code> for Spanish). It encapsulates the complete context needed to present information appropriately for a specific cultural group. This includes:</p>

<ul>
<li><strong>Language</strong>: The primary linguistic medium (e.g., <code>en</code>, <code>es</code>, <code>fr</code>).</li>
<li><strong>Script</strong>: The script (e.g., <code>Latn</code> for Latin, <code>Cyrl</code> for Cyrillic). For example, <code>zh-Hans</code> for Simplified Chinese, vs. <code>zh-Hant</code> for Traditional Chinese.</li>
<li><strong>Region</strong>: The geographic area (e.g., <code>US</code> for United States, <code>GB</code> for Great Britain, <code>DE</code> for Germany). This is crucial for variations within the same language, such as <code>en-US</code> vs. <code>en-GB</code>, which differ in date, time, and number formatting.</li>
<li><strong>Preferences/Variants</strong>: Further specific cultural or linguistic preferences. See <a href="https://www.w3.org/International/questions/qa-choosing-language-tags">“Choosing a Language Tag”</a> from W3C for more information.</li>
</ul>

<p>Typically, you’ll want to choose the locale according to the language of the web page. This can be determined from the <code>lang</code> attribute:</p>

<div class="break-out">
<pre><code class="language-javascript">// Get the page's language from the HTML lang attribute
const pageLocale = document.documentElement.lang || 'en-US'; // Fallback to 'en-US'
</code></pre>
</div>

<p>Occasionally, you may want to override the page locale with a specific locale, such as when displaying content in multiple languages:</p>

<div class="break-out">
<pre><code class="language-javascript">// Force a specific locale regardless of page language
const tutorialFormatter = new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' });

console.log(`Chinese example: ${tutorialFormatter.format(199.99)}`); // Output: ¥199.99
</code></pre>
</div>

<p>In some cases, you might want to use the user’s preferred language:</p>

<div class="break-out">
<pre><code class="language-javascript">// Use the user's preferred language
const browserLocale = navigator.language || 'ja-JP';

const formatter = new Intl.NumberFormat(browserLocale, { style: 'currency', currency: 'JPY' });
</code></pre>
</div>

<p>When you instantiate an <code>Intl</code> formatter, you can optionally pass one or more locale strings. The API will then select the most appropriate locale based on availability and preference.</p>

<h2 id="core-formatting-powerhouses">Core Formatting Powerhouses</h2>

<p>The <code>Intl</code> object exposes several constructors, each for a specific formatting task. Let’s delve into the most frequently used ones, along with some powerful, often-overlooked gems.</p>

<h3 id="1-intl-datetimeformat-dates-and-times-globally">1. <code>Intl.DateTimeFormat</code>: Dates and Times, Globally</h3>

<p>Formatting dates and times is a classic i18n problem. Should it be MM/DD/YYYY or DD.MM.YYYY? Should the month be a number or a full word? <code>Intl.DateTimeFormat</code> handles all this with ease.</p>

<div class="break-out">
<pre><code class="language-javascript">const date = new Date(2025, 6, 27, 14, 30, 0); // June 27, 2025, 2:30 PM

// Specific locale and options (e.g., long date, short time)
const options = {
  weekday: 'long',
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  hour: 'numeric',
  minute: 'numeric',
  timeZoneName: 'shortOffset' // e.g., "GMT+8"
};

console.log(new Intl.DateTimeFormat('en-US', options).format(date));

// "Friday, June 27, 2025 at 2:30 PM GMT+8"
console.log(new Intl.DateTimeFormat('de-DE', options).format(date));

// "Freitag, 27. Juni 2025 um 14:30 GMT+8"

// Using dateStyle and timeStyle for common patterns
console.log(new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short' }).format(date));

// "Friday 27 June 2025 at 14:30"

console.log(new Intl.DateTimeFormat('ja-JP', { dateStyle: 'long', timeStyle: 'short' }).format(date));

// "2025年6月27日 14:30"
</code></pre>
</div>

<p>The flexibility of <code>options</code> for <code>DateTimeFormat</code> is vast, allowing control over year, month, day, weekday, hour, minute, second, time zone, and more.</p>

<h3 id="2-intl-numberformat-numbers-with-cultural-nuance">2. <code>Intl.NumberFormat</code>: Numbers With Cultural Nuance</h3>

<p>Beyond simple decimal places, numbers require careful handling: thousands separators, decimal markers, currency symbols, and percentage signs vary wildly across locales.</p>

<div class="break-out">
<pre><code class="language-javascript">const price = 123456.789;

// Currency formatting
console.log(new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(price));

// "$123,456.79" (auto-rounds)

console.log(new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price));

// "123.456,79 €"

// Units
console.log(new Intl.NumberFormat('en-US', { style: 'unit', unit: 'meter', unitDisplay: 'long' }).format(100));

// "100 meters"

console.log(new Intl.NumberFormat('fr-FR', { style: 'unit', unit: 'kilogram', unitDisplay: 'short' }).format(5.5));

// "5,5 kg"
</code></pre>
</div>

<p>Options like <code>minimumFractionDigits</code>, <code>maximumFractionDigits</code>, and <code>notation</code> (e.g., <code>scientific</code>, <code>compact</code>) provide even finer control.</p>

<h3 id="3-intl-listformat-natural-language-lists">3. <code>Intl.ListFormat</code>: Natural Language Lists</h3>

<p>Presenting lists of items is surprisingly tricky. English uses “and” for conjunction and “or” for disjunction. Many languages have different conjunctions, and some require specific punctuation.</p>

<p>This API simplifies a task that would otherwise require complex conditional logic:</p>

<div class="break-out">
<pre><code class="language-javascript">const items = ['apples', 'oranges', 'bananas'];

// Conjunction ("and") list
console.log(new Intl.ListFormat('en-US', { type: 'conjunction' }).format(items));

// "apples, oranges, and bananas"

console.log(new Intl.ListFormat('de-DE', { type: 'conjunction' }).format(items));

// "Äpfel, Orangen und Bananen"

// Disjunction ("or") list
console.log(new Intl.ListFormat('en-US', { type: 'disjunction' }).format(items));

// "apples, oranges, or bananas"

console.log(new Intl.ListFormat('fr-FR', { type: 'disjunction' }).format(items));

// "apples, oranges ou bananas"
</code></pre>
</div>

<h3 id="4-intl-relativetimeformat-human-friendly-timestamps">4. <code>Intl.RelativeTimeFormat</code>: Human-Friendly Timestamps</h3>

<p>Displaying “2 days ago” or “in 3 months” is common in UI, but localizing these phrases accurately requires extensive data. <code>Intl.RelativeTimeFormat</code> automates this.</p>

<div class="break-out">
<pre><code class="language-javascript">const rtf = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' });

console.log(rtf.format(-1, 'day'));    // "yesterday"
console.log(rtf.format(1, 'day'));     // "tomorrow"
console.log(rtf.format(-7, 'day'));    // "7 days ago"
console.log(rtf.format(3, 'month'));   // "in 3 months"
console.log(rtf.format(-2, 'year'));   // "2 years ago"

// French example:
const frRtf = new Intl.RelativeTimeFormat('fr-FR', { numeric: 'auto', style: 'long' });

console.log(frRtf.format(-1, 'day'));    // "hier"
console.log(frRtf.format(1, 'day'));     // "demain"
console.log(frRtf.format(-7, 'day'));    // "il y a 7 jours"
console.log(frRtf.format(3, 'month'));   // "dans 3 mois"
</code></pre>
</div>

<p>The <code>numeric: 'always'</code> option would force “1 day ago” instead of “yesterday”.</p>

<h3 id="5-intl-pluralrules-mastering-pluralization">5. <code>Intl.PluralRules</code>: Mastering Pluralization</h3>

<p>This is arguably one of the most critical aspects of i18n. Different languages have vastly different pluralization rules (e.g., English has singular/plural, Arabic has zero, one, two, many&hellip;). <code>Intl.PluralRules</code> allows you to determine the “plural category” for a given number in a specific locale.</p>

<pre><code class="language-javascript">const prEn = new Intl.PluralRules('en-US');

console.log(prEn.select(0));    // "other" (for "0 items")
console.log(prEn.select(1));    // "one"   (for "1 item")
console.log(prEn.select(2));    // "other" (for "2 items")

const prAr = new Intl.PluralRules('ar-EG');

console.log(prAr.select(0));    // "zero"
console.log(prAr.select(1));    // "one"
console.log(prAr.select(2));    // "two"
console.log(prAr.select(10));   // "few"
console.log(prAr.select(100));  // "other"
</code></pre>

<p>This API doesn’t pluralize text directly, but it provides the essential classification needed to select the correct translation string from your message bundles. For example, if you have message keys like <code>item.one</code>, <code>item.other</code>, you’d use <code>pr.select(count)</code> to pick the right one.</p>

<h3 id="6-intl-displaynames-localized-names-for-everything">6. <code>Intl.DisplayNames</code>: Localized Names For Everything</h3>

<p>Need to display the name of a language, a region, or a script in the user’s preferred language? <code>Intl.DisplayNames</code> is your comprehensive solution.</p>

<div class="break-out">
<pre><code class="language-javascript">// Display language names in English
const langNamesEn = new Intl.DisplayNames(['en'], { type: 'language' });

console.log(langNamesEn.of('fr'));      // "French"
console.log(langNamesEn.of('es-MX'));   // "Mexican Spanish"

// Display language names in French
const langNamesFr = new Intl.DisplayNames(['fr'], { type: 'language' });

console.log(langNamesFr.of('en'));      // "anglais"
console.log(langNamesFr.of('zh-Hans')); // "chinois (simplifié)"

// Display region names
const regionNamesEn = new Intl.DisplayNames(['en'], { type: 'region' });

console.log(regionNamesEn.of('US'));    // "United States"
console.log(regionNamesEn.of('DE'));    // "Germany"

// Display script names
const scriptNamesEn = new Intl.DisplayNames(['en'], { type: 'script' });

console.log(scriptNamesEn.of('Latn'));  // "Latin"
console.log(scriptNamesEn.of('Arab'));  // "Arabic"
</code></pre>
</div>

<p>With <code>Intl.DisplayNames</code>, you avoid hardcoding countless mappings for language names, regions, or scripts, keeping your application robust and lean.</p>

<h2 id="browser-support">Browser Support</h2>

<p>You might be wondering about browser compatibility. The good news is that <code>Intl</code> has excellent support across modern browsers. All major browsers (Chrome, Firefox, Safari, Edge) fully support the core functionality discussed (<code>DateTimeFormat</code>, <code>NumberFormat</code>, <code>ListFormat</code>, <code>RelativeTimeFormat</code>, <code>PluralRules</code>, <code>DisplayNames</code>). You can confidently use these APIs without polyfills for the majority of your user base.</p>

<h2 id="conclusion-embrace-the-global-web-with-intl">Conclusion: Embrace The Global Web With <code>Intl</code></h2>

<p>The <code>Intl</code> API is a cornerstone of modern web development for a global audience. It empowers front-end developers to deliver <strong>highly localized user experiences</strong> with minimal effort, leveraging the browser’s built-in, optimized capabilities.</p>

<p>By adopting <code>Intl</code>, you <strong>reduce dependencies</strong>, <strong>shrink bundle sizes</strong>, and <strong>improve performance</strong>, all while ensuring your application respects and adapts to the diverse linguistic and cultural expectations of users worldwide. Stop wrestling with custom formatting logic and embrace this standards-compliant tool!</p>

<p>It’s important to remember that <code>Intl</code> handles the <em>formatting</em> of data. While incredibly powerful, it doesn’t solve every aspect of internationalization. Content translation, bidirectional text (RTL/LTR), locale-specific typography, and deep cultural nuances beyond data formatting still require careful consideration. (I may write about these in the future!) However, for presenting dynamic data accurately and intuitively, <code>Intl</code> is the browser-native answer.</p>

<h3 id="further-reading-resources">Further Reading &amp; Resources</h3>

<ul>
<li>MDN Web Docs:

<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl"><code>Intl namespace object</code></a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat"><code>Intl.DateTimeFormat</code></a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat"><code>Intl.NumberFormat</code></a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat"><code>Intl.ListFormat</code></a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat"><code>Intl.RelativeTimeFormat</code></a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules"><code>Intl.PluralRules</code></a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DisplayNames"><code>Intl.DisplayNames</code></a></li>
</ul></li>
<li>ECMAScript Internationalization API Specification: <a href="https://tc39.es/ecma402/">The official ECMA-402 Standard</a></li>
<li><a href="https://www.w3.org/International/questions/qa-choosing-language-tags">Choosing a Language Tag</a></li>
</ul>

<div class="signature">
  <img src="https://www.smashingmagazine.com/images/logo/logo--red.png" alt="Smashing Editorial" width="35" height="46" loading="lazy" decoding="async" />
  <span>(gg, yk)</span>
</div>


              </article>
            </body>
          </html>
        ]]></content:encoded></item><item><author>Amejimaobari Ollornwi</author><title>Handling JavaScript Event Listeners With Parameters</title><link>https://www.smashingmagazine.com/2025/07/handling-javascript-event-listeners-parameters/</link><pubDate>Mon, 21 Jul 2025 10:00:00 +0000</pubDate><guid>https://www.smashingmagazine.com/2025/07/handling-javascript-event-listeners-parameters/</guid><description>Event listeners are essential for interactivity in JavaScript, but they can quietly cause memory leaks if not removed properly. And what if your event listener needs parameters? That’s where things get interesting. Amejimaobari Ollornwi shares which JavaScript features make handling parameters with event handlers both possible and well-supported.</description><content:encoded><![CDATA[
          <html>
            <head>
              <meta charset="utf-8">
              <link rel="canonical" href="https://www.smashingmagazine.com/2025/07/handling-javascript-event-listeners-parameters/" />
              <title>Handling JavaScript Event Listeners With Parameters</title>
            </head>
            <body>
              <article>
                <header>
                  <h1>Handling JavaScript Event Listeners With Parameters</h1>
                  
                    
                    <address>Amejimaobari Ollornwi</address>
                  
                  <time datetime="2025-07-21T10:00:00&#43;00:00" class="op-published">2025-07-21T10:00:00+00:00</time>
                  <time datetime="2025-07-21T10:00:00&#43;00:00" class="op-modified">2026-05-12T16:11:33+00:00</time>
                </header>
                
                

<p>JavaScript event listeners are very important, as they exist in almost every web application that requires interactivity. As common as they are, it is also essential for them to be managed properly. Improperly managed event listeners can lead to memory leaks and can sometimes cause performance issues in extreme cases.</p>

<p>Here’s the real problem: <strong>JavaScript event listeners are often not removed after they are added.</strong> And when they are added, they do not require parameters most of the time &mdash; except in rare cases, which makes them a little trickier to handle.</p>

<p>A common scenario where you may need to use parameters with event handlers is when you have a dynamic list of tasks, where each task in the list has a “Delete” button attached to an event handler that uses the task’s ID as a parameter to remove the task. In a situation like this, it is a good idea to remove the event listener once the task has been completed to ensure that the deleted element can be successfully cleaned up, a process known as <a href="https://javascript.info/garbage-collection">garbage collecti</a><a href="https://javascript.info/garbage-collection">on</a>.</p>

<h2 id="a-common-mistake-when-adding-event-listeners">A Common Mistake When Adding Event Listeners</h2>

<p>A very common mistake when adding parameters to event handlers is calling the function with its parameters inside the <code>addEventListener()</code> method. This is what I mean:</p>

<pre><code class="language-javascript">button.addEventListener('click', myFunction(param1, param2));
</code></pre>

<p>The browser responds to this line by immediately calling the function, irrespective of whether or not the click event has happened. In other words, the function is invoked right away instead of being deferred, so it never fires when the click event actually occurs.</p>

<p>You may also receive the following console error in some cases:</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/handling-javascript-event-listeners-parameters/1-uncaught-typeerror.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="75"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/handling-javascript-event-listeners-parameters/1-uncaught-typeerror.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/handling-javascript-event-listeners-parameters/1-uncaught-typeerror.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/handling-javascript-event-listeners-parameters/1-uncaught-typeerror.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/handling-javascript-event-listeners-parameters/1-uncaught-typeerror.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/handling-javascript-event-listeners-parameters/1-uncaught-typeerror.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/handling-javascript-event-listeners-parameters/1-uncaught-typeerror.png"
			
			sizes="100vw"
			alt="Uncaught TypeError"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Uncaught TypeError: Failed to execute. <code>addEventListener</code> on <code>EventTarget</code>: parameter is not of type <code>Object</code>. (<a href='https://files.smashing.media/articles/handling-javascript-event-listeners-parameters/1-uncaught-typeerror.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>This error makes sense because the second parameter of the <code>addEventListener</code> method <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#listener">can only accept</a> a JavaScript function, an object with a <code>handleEvent()</code> method, or simply <code>null</code>. A quick and easy way to avoid this error is by changing the second parameter of the <code>addEventListener</code> method to an arrow or anonymous function.</p>

<pre><code class="language-javascript">button.addEventListener('click', (event) =&gt; {
  myFunction(event, param1, param2); // Runs on click
});
</code></pre>

<p>The only hiccup with using arrow and anonymous functions is that they cannot be removed with the traditional <code>removeEventListener()</code> method; you will have to make use of <code>AbortController</code>, which may be overkill for simple cases. <code>AbortController</code> shines when you have multiple event listeners to remove at once.</p>

<p>For simple cases where you have just one or two event listeners to remove, the <code>removeEventListener()</code> method still proves useful. However, in order to make use of it, you’ll need to store your function as a reference to the listener.</p>

<h2 id="using-parameters-with-event-handlers">Using Parameters With Event Handlers</h2>

<p>There are several ways to include parameters with event handlers. However, for the purpose of this demonstration, we are going to constrain our focus to the following two:</p>

<h3 id="option-1-arrow-and-anonymous-functions">Option 1: Arrow And Anonymous Functions</h3>

<p>Using arrow and anonymous functions is the fastest and easiest way to get the job done.</p>

<p>To add an event handler with parameters using arrow and anonymous functions, we’ll first need to call the function we’re going to create inside the arrow function attached to the event listener:</p>

<pre><code class="language-javascript">const button = document.querySelector("#myButton");

button.addEventListener("click", (event) =&gt; {
  handleClick(event, "hello", "world");
});
</code></pre>

<p>After that, we can create the function with parameters:</p>

<pre><code class="language-javascript">function handleClick(event, param1, param2) {
  console.log(param1, param2, event.type, event.target);
}
</code></pre>

<p>Note that with this method, removing the event listener requires the <a href="https://developer.mozilla.org/en-US/docs/Web/API/AbortController"><code>AbortController</code></a>. To remove the event listener, we create a new <code>AbortController</code> object and then retrieve the <code>AbortSignal</code> object from it:</p>

<pre><code class="language-javascript">const controller = new AbortController();
const { signal } = controller;
</code></pre>

<p>Next, we can pass the <code>signal</code> from the <code>controller</code> as an option in the <code>removeEventListener()</code> method:</p>

<pre><code class="language-javascript">button.addEventListener("click", (event) =&gt; {
  handleClick(event, "hello", "world");
}, { signal });
</code></pre>

<p>Now we can remove the event listener by calling <code>AbortController.abort()</code>:</p>

<pre><code class="language-javascript">controller.abort()
</code></pre>

<h3 id="option-2-closures">Option 2: Closures</h3>

<p>Closures in JavaScript are another feature that can help us with event handlers. Remember the mistake that produced a type error? That mistake can also be corrected with closures. Specifically, with closures, a function can access variables from its outer scope.</p>

<p>In other words, we can access the parameters we need in the event handler from the outer function:</p>

<div class="break-out">
<pre><code class="language-javascript">function createHandler(message, number) {
  // Event handler
  return function (event) {
  console.log(`${message} ${number} - Clicked element:`, event.target);
    };
  }

  const button = document.querySelector("&#35;myButton");
  button.addEventListener("click", createHandler("Hello, world!", 1));
}
</code></pre>
</div>

<p>This establishes a function that returns another function. The function that is created is then called as the second parameter in the <code>addEventListener()</code> method so that the inner function is returned as the event handler. And with the power of closures, the parameters from the outer function will be made available for use in the inner function.</p>

<p>Notice how the <code>event</code> object is made available to the inner function. This is because the inner function is what is being attached as the event handler. The event object is passed to the function automatically because it’s the event handler.</p>

<p>To remove the event listener, we can use the <code>AbortController</code> like we did before. However, this time, let’s see how we can do that using the <code>removeEventListener()</code> method instead.</p>

<p>In order for the <code>removeEventListener</code> method to work, a reference to the <code>createHandler</code> function needs to be stored and used in the <code>addEventListener</code> method:</p>

<div class="break-out">
<pre><code class="language-javascript">function createHandler(message, number) {
  return function (event) {
    console.log(`${message} ${number} - Clicked element:`, event.target);
  };
}
const handler = createHandler("Hello, world!", 1);
button.addEventListener("click", handler);
</code></pre>
</div>

<p>Now, the event listener can be removed like this:</p>

<pre><code class="language-javascript">button.removeEventListener("click", handler);
</code></pre>

<h2 id="conclusion">Conclusion</h2>

<p>It is good practice to always remove event listeners whenever they are no longer needed to prevent memory leaks. Most times, event handlers do not require parameters; however, in rare cases, they do. Using JavaScript features like closures, <code>AbortController</code>, and <code>removeEventListener</code>, handling parameters with event handlers is both possible and well-supported.</p>

<div class="signature">
  <img src="https://www.smashingmagazine.com/images/logo/logo--red.png" alt="Smashing Editorial" width="35" height="46" loading="lazy" decoding="async" />
  <span>(gg, yk)</span>
</div>


              </article>
            </body>
          </html>
        ]]></content:encoded></item><item><author>Gabriel Shoyombo</author><title>CSS Intelligence: Speculating On The Future Of A Smarter Language</title><link>https://www.smashingmagazine.com/2025/07/css-intelligence-speculating-future-smarter-language/</link><pubDate>Wed, 02 Jul 2025 13:00:00 +0000</pubDate><guid>https://www.smashingmagazine.com/2025/07/css-intelligence-speculating-future-smarter-language/</guid><description>CSS has evolved from a purely presentational language into one with growing logical powers — thanks to features like container queries, relational pseudo-classes, and the &lt;code>if()&lt;/code> function. Is it still just for styling, or is it becoming something more? Gabriel Shoyombo explores how smart CSS has become over the years, where it is heading, the challenges it addresses, whether it is becoming too complex, and how developers are reacting to this shift.</description><content:encoded><![CDATA[
          <html>
            <head>
              <meta charset="utf-8">
              <link rel="canonical" href="https://www.smashingmagazine.com/2025/07/css-intelligence-speculating-future-smarter-language/" />
              <title>CSS Intelligence: Speculating On The Future Of A Smarter Language</title>
            </head>
            <body>
              <article>
                <header>
                  <h1>CSS Intelligence: Speculating On The Future Of A Smarter Language</h1>
                  
                    
                    <address>Gabriel Shoyombo</address>
                  
                  <time datetime="2025-07-02T13:00:00&#43;00:00" class="op-published">2025-07-02T13:00:00+00:00</time>
                  <time datetime="2025-07-02T13:00:00&#43;00:00" class="op-modified">2026-05-12T16:11:33+00:00</time>
                </header>
                
                

<p>Once upon a time, CSS was purely presentational. It imperatively handled the fonts, colors, backgrounds, spacing, and layouts, among other styles, for markup languages. It was a <strong>language for looks</strong>, doing what it was asked to, never thinking or making decisions. At least, that was what it was made for when <a href="https://www.w3.org/People/howcome/">Håkon Wium Lie proposed CSS in 1994</a>, and the World Wide Web Consortium (W3C) adopted it two years later.</p>

<p>Fast-forward to today, a lot has changed with the addition of new features, and more are on the way that shift the style language to a more imperative paradigm. CSS now actively powers complex responsive and interactive user interfaces. With recent advancements like <a href="https://www.smashingmagazine.com/2021/05/complete-guide-css-container-queries/">container queries</a>, <a href="https://www.smashingmagazine.com/2021/06/has-native-css-parent-selector/">relational pseudo-classes</a>, and <a href="https://www.w3.org/TR/css-values-5/#if-notation">the <code>if()</code> function</a>, the language once within the <strong>domains of presentations</strong> has stepped foot into the <strong>territory of logic</strong>, reducing its reliance on the language that had handled its logical aspect to date, JavaScript.</p>

<p>This shift presents interesting questions about CSS and its future for developers. CSS has deliberately remained within the domains of styling alone for a while now, but is it time for that to change? Also, is CSS still a <strong>presentational language</strong> as it started, or is it becoming something more and bigger? This article explores how smart CSS has become over the years, where it is heading, the problems it is solving, whether it is getting too complex, and how developers are reacting to this shift.</p>

<h2 id="historical-context-css-s-intentional-simplicity">Historical Context: CSS’s Intentional Simplicity</h2>

<p>A glimpse into CSS history shows a language born to separate content from presentation, making web pages easier to manage and maintain. The first official version of CSS, <a href="https://www.w3.org/TR/CSS1/">CSS1</a>, was released in 1996, and it introduced basic styling capabilities like font properties, colors, box model (padding, margin, and border), sizes (width and height), a few simple displays (none, block, and inline), and basic selectors.</p>

<p>Two years later, <a href="https://www.w3.org/TR/CSS2/">CSS2 was launched</a> and expanded what CSS could style in HTML with features like positioning, <code>z-index</code>, enhanced selectors, table layouts, and media types for different devices. However, there were inconsistencies within the style language, an issue CSS2.1 resolved in 2011, becoming the standard for modern CSS. It simplified web authoring and site maintenance.</p>

<p>CSS was largely <strong>static</strong> and <strong>declarative</strong> during the years between CSS1 and CSS2.1. Developers experienced a mix of frustrations and breakthroughs for their projects. Due to the absence of intuitive layouts like Flexbox and CSS Grid, developers relied on hacky alternatives with table layouts, positioning, or floats to get around complex designs, even though <a href="https://www.w3.org/TR/CSS1/#floating-elements">floats were originally designed for text to wrap around an obstacle</a> on a webpage, usually a media object. As a result, developers faced issues with collapsing containers and unexpected wrapping behaviour. Notwithstanding, basic styling was intuitive. A newbie could easily pick up web development today and add basic styling the next day. CSS was separated from content and logic, and as a result, it was <strong>highly performant</strong> and <strong>lightweight</strong>.</p>

<div data-audience="non-subscriber" data-remove="true" class="feature-panel-container">

<aside class="feature-panel" style="">
<div class="feature-panel-left-col">

<div class="feature-panel-description"><p>Meet <strong><a data-instant href="https://www.smashingconf.com/online-workshops/">Smashing Workshops</a></strong> on <strong>front-end, design &amp; UX</strong>, with practical takeaways, live sessions, <strong>video recordings</strong> and a friendly Q&amp;A. With Brad Frost, Stéph Walter and <a href="https://smashingconf.com/online-workshops/workshops">so many others</a>.</p>
<a data-instant href="smashing-workshops" class="btn btn--green btn--large" style="">Jump to the workshops&nbsp;↬</a></div>
</div>
<div class="feature-panel-right-col"><a data-instant href="smashing-workshops" class="feature-panel-image-link">
<div class="feature-panel-image">
<img
    loading="lazy"
    decoding="async"
    class="feature-panel-image-img"
    src="/images/smashing-cat/cat-scubadiving-panel.svg"
    alt="Feature Panel"
    width="257"
    height="355"
/>

</div>
</a>
</div>
</aside>
</div>

<h2 id="css3-the-first-step-toward-context-awareness">CSS3: The First Step Toward Context Awareness</h2>

<p>Things changed <a href="https://www.smashingmagazine.com/2012/07/learning-css3-useful-reference-guide/">when CSS3 rolled out</a>. Developers had expected a single monolithic update like the previous versions, but their expectations and the reality of the latest release were unmatched. The CSS3 red carpet revealed a <strong>modular system</strong> with powerful layout tools like Flexbox, CSS Grid, and media queries, defining for the first time how developers establish responsive designs. <a href="https://www.w3.org/Style/CSS/current-work">With over 20 modules</a>, CSS3 marked the inception of a <strong>“smarter CSS”</strong>.</p>

<p>Flexbox’s introduction around 2012 provided a flexible, one-dimensional layout system, while CSS Grid, launched in 2017, took layout a step further by offering a two-dimensional layout framework, making complex designs with minimal code possible. These advancements, as discussed by <a href="https://origin-blog.mediatemple.net/design-creative/five-huge-css-milestones/">Chris Coyier</a>, reduced reliance on hacks like floats.</p>

<p>It did not stop there. There’s <a href="https://www.w3.org/TR/mediaqueries-3/">media queries</a>, a prominent release of CSS3, that is one of the major contributors to this <em>smart CSS</em>. With media queries, CSS can react to different devices’ screens, adjusting its styles to fit the screen dimensions, aspect ratio, and orientation, a feat that earlier versions could not easily achieve. In the fifth level, it added <a href="https://www.w3.org/TR/mediaqueries-5/#mf-user-preferences">user preference media features</a> such as <code>prefers-color-scheme</code> and <code>prefers-reduced-motion</code>, making CSS more <strong>user-centric</strong> by adapting styles to user settings, <strong>enhancing accessibility</strong>.</p>

<p>CSS3 marked the beginning of a <strong>context-aware CSS</strong>.</p>

<blockquote>Context-awareness means the ability to understand and react to the situation around you or in your environment accordingly. It means systems and devices can sense critical information, like your location, time of day, and activity, and adjust accordingly.</blockquote>

<p>In web development, the term “context-awareness” has always been used with components, but what drives a context-aware component? If you mentioned anything other than the component’s styles, you would be wrong! For a component to be considered context-aware, <a href="https://www.lukeleber.com/blog/2024-07-25-context-aware-components">it needs to feel its environment’s presence</a> and know what happens in it. For instance, for your website to update its styles to accommodate a dark mode interface, it needs to be aware of the user’s preferences. Also, to change its layout, a website needs to know the device a user is accessing it on &mdash; and thanks to user preference media queries, that is possible.</p>

<p>Despite these features, CSS remained largely reactive. It responded to external factors like screen size (via media queries) or input states (like <code>:hover</code>, <code>:focus</code>, or <code>:checked</code>), but it never made decisions based on the changes in its environment. Developers typically turn to JavaScript for that level of interaction.</p>

<p>However, not anymore.</p>

<p>For example, with container queries and, more recently, <a href="https://www.smashingmagazine.com/2024/06/what-are-css-container-style-queries-good-for/">container <em>style</em> queries</a>, CSS now responds not only to layout constraints but to <strong>design intent</strong>. It can adjust based on a component’s environment and even its parent’s theme or state. And that’s not all. The recently specced <code>if()</code> function promises <strong>inline conditional logic</strong>, <a href="https://css-tricks.com/if-css-gets-inline-conditionals/">allowing styles to change based on conditions</a>, all of which can be achieved without scripting.</p>

<p>These developments suggest CSS is moving beyond presentation to handle behaviour, challenging its traditional role.</p>

<h2 id="new-css-features-driving-intelligence">New CSS Features Driving Intelligence</h2>

<p>Several features are currently pushing CSS towards a dynamic and adaptive edge, thereby making it smarter, but these two are worth mentioning: container style queries and the <code>if()</code> function.</p>

<h3 id="what-are-container-style-queries-and-why-do-they-matter">What Are Container Style Queries, And Why Do They Matter?</h3>

<p>To better understand what container style queries are, it makes sense to make a quick stop at a close cousin: container size queries introduced in the <a href="https://www.w3.org/TR/css-contain-3/">CSS Containment Module Level 3</a>.</p>

<p><a href="https://www.smashingmagazine.com/2021/05/complete-guide-css-container-queries/">Container size queries</a> allow developers to style elements based on the dimensions of their parent container. This is a huge win for component-based designs as it eliminates the need to shoehorn responsive styles into global media queries.</p>

<pre><code class="language-css">/&#42; Size-based container query &#42;/
@container (min-width: 500px) {
  .card {
    flex-direction: row;
  }
}
</code></pre>

<p><a href="https://css-tricks.com/css-container-queries/#aa-container-style-queries">Container style queries</a> take it a step further by allowing you to style elements based on custom properties (aka CSS variables) set on the container.</p>

<pre><code class="language-css">/&#42; Style-based container query &#42;/
@container style(--theme: dark) {
  .button {
    background: black;
    color: white;
  }
}
</code></pre>

<p>These features are a big deal in CSS because they unlock <strong>context-aware components</strong>. A button can change appearance based on a <code>--theme</code> property set by a parent without using JavaScript or hardcoded classes.</p>

<h3 id="the-if-function-a-glimpse-into-the-future">The <code>if()</code> Function: A Glimpse Into The Future</h3>

<p>The CSS <code>if()</code> function might just be the most radical shift yet. When implemented (Chrome is the only one to support it, <a href="https://developer.chrome.com/blog/new-in-chrome-137?hl=en#if">as of version 137</a>), it would allow developers to write inline conditional logic directly in property declarations. Think of the <em>ternary operator</em> in CSS.</p>

<pre><code class="language-css">padding: if(style(--theme: dark): 2rem; else: 3rem);
</code></pre>

<p>This hypothetical line or pseudo code, <em>not syntax</em>, sets the text color to white <em>if</em> the <code>--theme</code> variable equals <code>dark</code>, or black otherwise. Right now, the <code>if()</code> function is not supported in any browser, but it is on the radar of the CSS Working Group, and influential developers like <a href="https://lea.verou.me/blog/2024/css-conditionals/">Lea Verou</a> are already exploring its possibilities.</p>

<div class="partners__lead-place"></div>

<h2 id="the-new-css-is-the-boundary-between-css-and-javascript-blurring">The New CSS: Is The Boundary Between CSS And JavaScript Blurring?</h2>

<p>Traditionally, the separation of concerns concerning styling was thus: <a href="https://medium.com/@giosterr44/mastering-the-basics-why-html-css-javascript-are-still-essential-c0343ab485b4">CSS for how things look and JavaScript for how things behave</a>. However, features like container style queries and the specced <code>if()</code> function are starting to blur the line. CSS is beginning to <em>behave</em>, not in the sense of API calls or event listeners, but in the ability to conditionally apply styles based on logic or context.</p>

<p>As web development evolved, CSS started encroaching on JavaScript territory. CSS3 brought in animations and transitions, a powerful combination for interactive web development, which was impossible without JavaScript in the earlier days. Today, research proves that CSS has taken on several interactive tasks previously handled by JavaScript. For example, the <code>:hover</code> pseudo-class and <code>transition</code> property allow for visual feedback and smooth animations, as discussed in “<a href="https://www.smashingmagazine.com/2011/02/bringing-interactivity-to-your-website-with-web-standards/">Bringing Interactivity To Your Website With Web Standards</a>”.</p>

<p>That’s not all. Toggling accordions and modals existed within the domains of JavaScript before, but today, this is possible with new <a href="https://pagepro.co/blog/html-css-vs-javascript/">powerful CSS combos like the <code>&lt;details&gt;</code> and <code>&lt;summary&gt;</code> HTML tags for accordions or modals with the <code>:target</code> pseudo-class</a>. CSS can also handle tooltips using <code>aria-label</code> with <code>content: attr(aria-label)</code>, and star ratings with radio inputs and labels, as detailed in the same <a href="https://pagepro.co/blog/html-css-vs-javascript/">article</a>.</p>

<p>Another article, “<a href="https://blog.logrocket.com/5-things-you-can-do-with-css-instead-of-javascript/">5 things you can do with CSS instead of JavaScript</a>”, lists features like <code>scroll-behavior: smooth</code> for smooth scrolling and <code>@media (prefers-color-scheme: dark)</code> for dark mode, tasks that once required JavaScript. In the same article, you can also see that it’s possible to create a carousel without JavaScript by using the CSS scroll snapping functionality (and we’re not even talking about features designed specifically for creating carousels solely in CSS, recently <a href="https://developer.chrome.com/blog/carousels-with-css?hl=en">prototyped in Chrome</a>).</p>

<p>These extensions of CSS into the JavaScript domain have now left the latter with handling only complex, crucial interactions in a web application, such as user inputs, making API calls, and managing state. While the CSS pseudo-classes like <code>:valid</code> and <code>:invalid</code> can help as error or success indicators in input elements, you still need JavaScript for dynamic content updates, form validation, and real-time data fetching.</p>

<p>CSS now solves problems that many developers never knew existed. With JavaScript out of the way in many style scenarios, developers now have simplified codebases. The dependencies are fewer, the overheads are lower, and website performance is better, especially on mobile devices. In fact, this shift leans CSS towards a <strong>more accessible web</strong>, as CSS-driven designs are often easier for browsers and assistive technologies to process.</p>

<p>While the new features come with a lot of benefits, they also introduce complexities that did not exist before:</p>

<ul>
<li>What happens when logic is spread across both CSS and JavaScript?</li>
<li>How do we <strong>debug conditional styles</strong> without a clear view of what triggered them?</li>
<li>CSS only had to deal with basic styling like colors, fonts, layouts, and spacing, which were easier for new developers to onboard. How hard does the <strong>learning curve</strong> become as these new features require understanding concepts once exclusive to JavaScript?</li>
</ul>

<p>Developers are split. While some welcome the idea of a natural evolution of a smarter, more component-aware web, <a href="https://css-tricks.com/is-there-too-much-css-now/">others worry CSS is becoming too complex</a> &mdash; a language originally designed for formatting documents now juggling logic trees and style computation.</p>

<h2 id="divided-perspective-is-logic-in-css-helpful-or-harmful">Divided Perspective: Is Logic In CSS Helpful Or Harmful?</h2>

<p>While the evidence in the previous section leans towards boundary-blurring, there’s significant <strong>controversy among developers</strong>. Many modern developers argue that logic in CSS is long overdue. As web development grows more componentized, the limitations of declarative styling have become more apparent, causing proponents to see logic as a necessary evolution for a once purely styling language.</p>

<p>For instance, in frontend libraries like React, components often require conditional styles based on props or states. Developers have had to make do with JavaScript or CSS-in-JS solutions for such cases, but the truth remains that these solutions are not right. They introduce complexity and couple styles and logic. CSS and JavaScript are meant to have standalone concerns in web development, <a href="https://css-tricks.com/the-differing-perspectives-on-css-in-js/">but libraries like CSS-in-JS have ignored the rules and combined both</a>.</p>

<p>We have seen how preprocessors like SASS and LESS proved the usefulness of conditionals, loops, and variables in styling. Developers who do not accept the CSS in JavaScript approach have settled for these preprocessors. Nevertheless, like <a href="https://x.com/argyleink/status/1317304102460608512?t=rgyYyNApPOZt8iqh8NTEUQ&amp;s=19">Adam Argyle</a>, they voice their need for native CSS solutions. With native conditionals, developers could reduce JavaScript overhead and avoid runtime class toggling to achieve conditional presentation.</p>

<blockquote>“It never felt right to me to manipulate style settings in JavaScript when CSS is the right tool for the job. With CSS custom properties, we can send to CSS what needs to come from JavaScript.”<br /><br />&mdash; <a href="https://x.com/codepo8/status/1358082931122724864">Chris Heilmann</a></blockquote>

<p>Also, Bob Ziroll <a href="https://x.com/bobziroll/status/1819078139055595669">dislikes using JavaScript for what CSS is meant to handle</a> and finds it unnecessary. This reflects a preference for using CSS for styling tasks, even when JavaScript is involved. These developers embrace CSS’s new capabilities, seeing it as a way to reduce JavaScript dependency for performance reasons.</p>

<p>Others argue against it. Introducing logic into CSS is a slippery slope, and CSS could lose its core strengths &mdash; simplicity, readability, and accessibility &mdash; by becoming too much like a programming language. The fear is that developers run the risk of <a href="https://www.smashingmagazine.com/2024/02/web-development-getting-too-complex/">complicating the web more than it is supposed to be</a>.</p>

<blockquote>“I’m old-fashioned. I like my CSS separated from my HTML; my HTML separated from my JS; my JS separated from my CSS.”<br /><br />&mdash; <a href="https://x.com/SaraSoueidan/status/1273181281103351812">Sara Soueidan</a></blockquote>

<p>This view emphasises the traditional separation of concerns, arguing that mixing roles can complicate maintenance. Additionally, Brad Frost has also <a href="https://x.com/brad_frost/status/993189025132490755">expressed skepticism</a> when talking specifically about CSS-in-JS, stating that it, <em>“doesn’t scale to non-JS-framework environments, adds more noise to an already-noisy JS file, and the demos/examples I have seen haven’t embodied CSS best practices.”</em> This highlights concerns about scalability and best practices, suggesting that <strong>the blurred boundary might not always be beneficial</strong>.</p>

<p>Community discussions, such as on <a href="https://stackoverflow.com/questions/24012569/is-it-always-better-to-use-css-when-possible-instead-of-js">Stack Overflow</a>, also reflect this divide. A question like <em>“Is it always better to use CSS when possible instead of JS?”</em> receives answers favouring CSS for performance and simplicity, but others argue JavaScript is necessary for complex scenarios, illustrating the ongoing debate. Don’t be fooled. It might seem convenient to agree that CSS performs better than JavaScript in styling, <a href="https://css-tricks.com/myth-busting-css-animations-vs-javascript/">but that’s not always the case</a>.</p>

<h2 id="a-smarter-css-without-losing-its-soul">A Smarter CSS Without Losing Its Soul</h2>

<p>CSS has always stood apart from full-blown programming languages, like JavaScript, by being declarative, accessible, and purpose-driven.</p>

<blockquote class="pull-quote">
  <p>
    <a class="pull-quote__link" aria-label="Share on Twitter" href="https://twitter.com/share?text=%0aIf%20CSS%20is%20to%20grow%20more%20intelligent,%20the%20challenge%20lies%20not%20in%20making%20it%20more%20powerful%20for%20its%20own%20sake%20but%20in%20evolving%20it%20without%20compromising%20its%20major%20concern.%0a&url=https://smashingmagazine.com%2f2025%2f07%2fcss-intelligence-speculating-future-smarter-language%2f">
      
If CSS is to grow more intelligent, the challenge lies not in making it more powerful for its own sake but in evolving it without compromising its major concern.

    </a>
  </p>
  <div class="pull-quote__quotation">
    <div class="pull-quote__bg">
      <span class="pull-quote__symbol">“</span></div>
  </div>
</blockquote>

<p>So, what might a logically enriched but <em>still declarative</em> CSS look like? Let’s find out.</p>

<h3 id="conditional-rules-if-when-else-with-carefully-introduced-logic">Conditional Rules (<code>if</code>, <code>@when</code>…<code>@else</code>) With Carefully Introduced Logic</h3>

<p>A major frontier in CSS evolution is the introduction of native conditionals via the <a href="https://chromestatus.com/feature/6313805904347136"><code>if()</code></a> function and the <code>@when</code>…<code>@else</code> at-rules, which are part of the <a href="https://drafts.csswg.org/css-conditional-5/">CSS Conditional Rules Module Level 5</a> specification. While still in the early draft stages, this would allow developers to apply styles based on evaluated conditions without turning to JavaScript or a preprocessor. Unlike JavaScript’s imperative nature, these conditionals aim to keep logic ingrained in CSS’s existing flow, aligned with the cascade and specificity.</p>

<h3 id="more-powerful-intentional-selectors">More Powerful, Intentional Selectors</h3>

<p>Selectors have always been one of the major strengths of CSS, and expanding them in a targeted way would make it easier to express relationships and conditions declaratively without needing classes or scripts. Currently, <a href="https://css-tricks.com/the-css-has-selector/"><code>:has()</code></a> lets developers style a parent based on a child, and <code>:nth-child(An+B [of S]?)</code> (<a href="https://www.w3.org/TR/selectors-4/">in Selectors Level 4</a>) allows for more complex matching patterns. Together, they allow greater precision without altering CSS’s nature.</p>

<h3 id="scoped-styling-without-javascript">Scoped Styling Without JavaScript</h3>

<p>One of the challenges developers face in component-based frameworks like React or Vue is style scoping. Style scoping ensures styles apply only to specific elements or components and do not leak out. In the past, to achieve this, you needed to implement BEM naming conventions, CSS-in-JS, or build tools like CSS Modules. Native scoped styling in CSS, via the new experimental <a href="https://css-tricks.com/almanac/rules/s/scope/"><code>@scope</code></a> rule, allows developers to encapsulate styles in a specific context without extra tooling. This feature makes CSS more modular without tying it to JavaScript logic or complex class systems.</p>

<p>A fundamental design question now is whether we could empower CSS without making it like JavaScript. The truth is, to empower CSS with conditional logic, powerful selectors, and scoped rules, we don’t need it to mirror JavaScript’s syntax or complexity. The goal is declarative expressiveness, giving CSS more awareness and control while retaining its clear, readable nature, and we should focus on that. When done right, smarter CSS can amplify the language’s strengths rather than dilute them.</p>

<blockquote class="pull-quote">
  <p>
    <a class="pull-quote__link" aria-label="Share on Twitter" href="https://twitter.com/share?text=%0aThe%20real%20danger%20is%20not%20logic%20itself%20but%20unchecked%20complexity%20that%20obscures%20the%20simplicity%20with%20which%20CSS%20was%20built.%0a&url=https://smashingmagazine.com%2f2025%2f07%2fcss-intelligence-speculating-future-smarter-language%2f">
      
The real danger is not logic itself but unchecked complexity that obscures the simplicity with which CSS was built.

    </a>
  </p>
  <div class="pull-quote__quotation">
    <div class="pull-quote__bg">
      <span class="pull-quote__symbol">“</span></div>
  </div>
</blockquote>

<div class="partners__lead-place"></div>

<h2 id="cautions-and-constraints-why-smart-isn-t-always-better">Cautions And Constraints: Why Smart Isn’t Always Better</h2>

<p>The push for a smarter CSS comes with significant trade-offs alongside control and flexibility. Over the years, <a href="https://www.quora.com/In-general-does-adding-features-to-a-programming-language-make-it-better">history has shown that adding a new feature to a language or framework, or library, most likely introduces complexity</a>, not just for newbies, but also for expert developers. The danger is not in CSS gaining power but in how that power is implemented, taught, and used.</p>

<p>One of CSS’s greatest strengths has always been its <strong>approachability</strong>. Designers and beginners could learn the basics quickly: selectors, properties, and values. With more logic, scoping, and advanced selectors being introduced, that learning curve steepens. The risk is a widening gap between “basic CSS” and “real-world CSS”, echoing what happened with JavaScript and its ecosystem.</p>

<p>As CSS becomes more powerful, developers increasingly lean on tooling to manage and abstract that power, like building systems (e.g., webpack, Vite), linters and formatters, and component libraries with strict styling conventions. This creates dependencies that are hard to escape. <strong>Tooling becomes a prerequisite, not an option</strong>, further complicating onboarding and increasing setup time for projects that used to work with a single stylesheet.</p>

<p>Also, more logic means more potential for <strong>unexpected outcomes</strong>. New issues might arise that are harder to spot and fix. Resources like DevTools will then need to evolve to visualise scope boundaries, conditional applications, and complex selector chains. Until then, debugging may remain a challenge. <a href="https://robkendal.co.uk/blog/why-is-css-in-js-a-bad-or-good-idea/">All of these are challenges experienced with CSS-in-JS</a>; how much more Native CSS?</p>

<p>We’ve seen this before. CSS history is filled with overcomplicated workarounds, like tables for the layout before Flexbox, relying on floats with clear fix hacks, and overly rigid grid systems before native CSS Grid. In each case, the hacky solution eventually became the problem. CSS got better not by mimicking other languages but by <em>standardising thoughtful, declarative solutions</em>. With the right power, <a href="https://rachelandrew.co.uk/archives/2020/04/07/making-things-better/">we can make CSS better</a> at the end of the day.</p>

<h2 id="conclusion">Conclusion</h2>

<p>We just took a walk down the history lane of CSS, explored its presence, and peeked into what its future could be. We can all agree that CSS has come a long way from a simple, declarative language to a <strong>dynamic</strong>, <strong>context-aware</strong>, and, yes, <strong>smarter language</strong>. The evolution, of course, comes with tension: a smarter styling language with fewer dependencies on scripts and a complex one with a steeper learning curve.</p>

<p>This is what I conclude:</p>

<blockquote class="pull-quote">
  <p>
    <a class="pull-quote__link" aria-label="Share on Twitter" href="https://twitter.com/share?text=%0aThe%20future%20of%20CSS%20shouldn%e2%80%99t%20be%20a%20race%20to%20add%20logic%20for%20its%20own%20sake.%20Instead,%20it%20should%20be%20a%20thoughtful%20expansion,%20power%20balanced%20by%20clarity%20and%20innovation%20grounded%20in%20accessibility.%0a&url=https://smashingmagazine.com%2f2025%2f07%2fcss-intelligence-speculating-future-smarter-language%2f">
      
The future of CSS shouldn’t be a race to add logic for its own sake. Instead, it should be a thoughtful expansion, power balanced by clarity and innovation grounded in accessibility.

    </a>
  </p>
  <div class="pull-quote__quotation">
    <div class="pull-quote__bg">
      <span class="pull-quote__symbol">“</span></div>
  </div>
</blockquote>

<p>That means asking tough questions before shipping new features. It means ensuring that new capabilities help solve actual problems without introducing new barriers.</p>

<div class="signature">
  <img src="https://www.smashingmagazine.com/images/logo/logo--red.png" alt="Smashing Editorial" width="35" height="46" loading="lazy" decoding="async" />
  <span>(gg, yk)</span>
</div>


              </article>
            </body>
          </html>
        ]]></content:encoded></item><item><author>Myriam Frisano</author><title>Decoding The SVG &lt;code>path&lt;/code> Element: Curve And Arc Commands</title><link>https://www.smashingmagazine.com/2025/06/decoding-svg-path-element-curve-arc-commands/</link><pubDate>Mon, 23 Jun 2025 10:00:00 +0000</pubDate><guid>https://www.smashingmagazine.com/2025/06/decoding-svg-path-element-curve-arc-commands/</guid><description>On her quest to teach you how to code vectors by hand, Myriam Frisano’s second installment of a &lt;code>path&lt;/code> deep dive explores the most complex aspects of SVG’s most powerful element. She’ll help you understand the underlying rules and function of how curves and arcs are constructed. By the end of it, your toolkit is ready to tackle all types of tasks required to draw with code — even if some of the lines twist and turn.</description><content:encoded><![CDATA[
          <html>
            <head>
              <meta charset="utf-8">
              <link rel="canonical" href="https://www.smashingmagazine.com/2025/06/decoding-svg-path-element-curve-arc-commands/" />
              <title>Decoding The SVG &lt;code&gt;path&lt;/code&gt; Element: Curve And Arc Commands</title>
            </head>
            <body>
              <article>
                <header>
                  <h1>Decoding The SVG &lt;code&gt;path&lt;/code&gt; Element: Curve And Arc Commands</h1>
                  
                    
                    <address>Myriam Frisano</address>
                  
                  <time datetime="2025-06-23T10:00:00&#43;00:00" class="op-published">2025-06-23T10:00:00+00:00</time>
                  <time datetime="2025-06-23T10:00:00&#43;00:00" class="op-modified">2026-05-12T16:11:33+00:00</time>
                </header>
                
                

<p>In the <a href="https://www.smashingmagazine.com/2025/06/decoding-svg-path-element-line-commands/">first part of decoding the SVG <code>path</code> pair</a>, we mostly dealt with converting things from semantic tags (<code>line</code>, <code>polyline</code>, <code>polygon</code>) into the <code>path</code> command syntax, but the <code>path</code> element didn’t really offer us any new shape options. This will change in this article as we’re learning how to draw <strong>curves</strong> and <strong>arcs</strong>, which just refer to parts of an ellipse.</p>

<h2 id="tl-dr-on-previous-articles">TL;DR On Previous Articles</h2>

<p>If this is your first meeting with this series, I recommend you familiarize yourself with the <a href="https://www.smashingmagazine.com/2024/09/svg-coding-examples-recipes-writing-vectors-by-hand/">basics of hand-coding SVG</a>, as well as <a href="https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/marker">how the <code>&lt;marker&gt;</code> works</a> and have a <a href="https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/animate">basic understanding of animate</a>, as this guide doesn’t explain them. I also recommend knowing about the <code>M/m</code> command within the <code>&lt;path&gt;</code> <code>d</code> attribute (I wrote the aforementioned <a href="https://www.smashingmagazine.com/2025/06/decoding-svg-path-element-line-commands/">article on path line commands</a> to help).</p>

<p><strong>Note</strong>: <em>This article will solely focus on the syntax of curve and arc commands and not offer an introduction to <code>path</code> as an element.</em></p>

<p>Before we get started, I want to do a quick recap of how I code SVG, which is by using JavaScript. I don’t like dealing with numbers and math, and reading SVG code that has numbers filled into every attribute makes me lose all understanding of it. By giving coordinates names and having all my math easy to parse and all written out, I have a much better time with this type of code, and I think you will, too.</p>

<p>As the goal of this article is about understanding <code>path</code> syntax and not about doing placement or how to leverage loops and other more basic things, I will not run you through the entire setup of each example. I’ll share some snippets of the code, but please note that it may be slightly adjusted from the CodePen or simplified to make the article easier to read. However, if there are specific questions about code not part of the text that’s in the CodePen demos &mdash; the comment section is open, as always.</p>

<p>To keep this all framework-agnostic, the code is written in vanilla JavaScript, though, in practice, TypeScript comes highly recommended when dealing with complex images.</p>

<div data-audience="non-subscriber" data-remove="true" class="feature-panel-container">

<aside class="feature-panel" style="">
<div class="feature-panel-left-col">

<div class="feature-panel-description"><p>Meet <strong><a data-instant href="https://www.smashingconf.com/online-workshops/">Smashing Workshops</a></strong> on <strong>front-end, design &amp; UX</strong>, with practical takeaways, live sessions, <strong>video recordings</strong> and a friendly Q&amp;A. With Brad Frost, Stéph Walter and <a href="https://smashingconf.com/online-workshops/workshops">so many others</a>.</p>
<a data-instant href="smashing-workshops" class="btn btn--green btn--large" style="">Jump to the workshops&nbsp;↬</a></div>
</div>
<div class="feature-panel-right-col"><a data-instant href="smashing-workshops" class="feature-panel-image-link">
<div class="feature-panel-image">
<img
    loading="lazy"
    decoding="async"
    class="feature-panel-image-img"
    src="/images/smashing-cat/cat-scubadiving-panel.svg"
    alt="Feature Panel"
    width="257"
    height="355"
/>

</div>
</a>
</div>
</aside>
</div>

<h2 id="drawing-bézier-curves">Drawing Bézier Curves</h2>

<p>Being able to draw lines, polygons, polylines, and compounded versions of them is all fun and nice, but <code>path</code> can also do more than just offer more cryptic implementations of basic semantic SVG tags.</p>

<p>One of those additional types is Bézier curves.</p>

<p>There are multiple different curve commands. And this is where the idea of points and control points comes in.</p>

<blockquote><strong>Bézier math plotting is out of scope for this article.</strong><br />But, there is a visually gorgeous video by Freya Holmér called <a href="https://youtu.be/aVwxzDHniEw?si=WB_3i88VVJlZS6jf">The Beauty of Bézier Curves</a> which gets into the construction of cubic and quadratic bézier curves that features beautiful animation and the math becomes a lot easier to digest.</blockquote>

<p>Luckily, SVG allows us to draw quadratic curves with one control point and cubic curves with two control points without having to do any additional math.</p>

<p>So, what is a control point? A control point is the position of the handle that controls the curve. It is not a point that is drawn.</p>

<p>I found the best way to understand these path commands is to render them like a GUI, like Affinity and Illustrator would. Then, draw the “handles” and draw a few random curves with different properties, and see how they affect the curve. Seeing that animation also really helps to see the mechanics of these commands.</p>

<p>This is what I’ll be using markers and animation for in the following visuals. You will notice that the markers I use are rectangles and circles, and since they are connected to lines, I can make use of <code>marker</code> and then save myself a lot of animation time because these additional elements are rigged to the system. (And animating a single <code>d</code> command instead of <code>x</code> and <code>y</code> attributes separately makes the SVG code also much shorter.)</p>

<h3 id="quadratic-bézier-curves-q-t-commands">Quadratic Bézier Curves: <code>Q</code> &amp; <code>T</code> Commands</h3>

<p>The <code>Q</code> command is used to draw quadratic béziers. It takes two arguments: the control point and the end point.</p>

<p>So, for a simple curve, we would start with <code>M</code> to move to the start point, then <code>Q</code> to draw the curve.</p>

<div class="break-out">
<pre><code class="language-javascript">const path = `M${start.x} ${start.y} Q${control.x} ${control.y} ${end.x} ${end.y}`;
</code></pre>
</div>

<p>Since we have the Control Point, the Start Point, and the End Point, it’s actually quite simple to render the singular handle path like a graphics program would.</p>

<p>Funny enough, you probably have never interacted with a quadratic Bézier curve like with a cubic one in most common GUIs! Most of the common programs will convert this curve to a cubic curve with two handles and control points as soon as you want to play with it.</p>

<p>For the drawing, I created a couple of markers, and I’m drawing the handle in red to make it stand out a bit better.</p>

<p>I also stroked the main <code>path</code> with a gradient and gave it a crosshatch pattern fill. (We looked at <code>pattern</code> in <a href="https://www.smashingmagazine.com/2025/06/decoding-svg-path-element-line-commands/">my first article</a>, <a href="https://developer.mozilla.org/en-US/docs/Web/SVG/Element/linearGradient"><code>linearGradient</code></a> is fairly similar. They’re both <code>def</code> elements you can refer to via <code>id</code>.) I like seeing the fill, but if you find it distracting, you can modify the variable for it.</p>

<p>I encourage you to look at the example with and without the rendering of the handle to see some of the nuance that happens around the points as the control points get closer to them.</p>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="LEVXLoJ"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [SVG Path Quadratic Bézier Curve Visual [forked]](https://codepen.io/smashingmag/pen/LEVXLoJ) by <a href="https://codepen.io/halfapx">Myriam</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/LEVXLoJ">SVG Path Quadratic Bézier Curve Visual [forked]</a> by <a href="https://codepen.io/halfapx">Myriam</a>.</figcaption>
</figure>

<blockquote><strong>Quadratic Béziers are the “less-bendy” ones.</strong><br />These curves always remain somewhat related to “u” or “n” shapes and can’t be manipulated to be contorted. They can be squished, though.</blockquote>

<p>Connected Bézier curves are called “Splines”. And there is an additional command when chaining multiple quadratic curves, which is the <code>T</code> command.</p>

<p>The <code>T</code> command is used to draw a curve that is connected to the previous curve, so it always has to follow a <code>Q</code> command (or another <code>T</code> command). It only takes one argument, which is the endpoint of the curve.</p>

<div class="break-out">
<pre><code class="language-javascript">const path = `M${p1.x} ${p1.y} Q${cP.x} ${cP.y} ${p2.x} ${p2.y} T${p3.x} ${p3.y}`
</code></pre>
</div>

<p>The <code>T</code> command will actually use information about our control Point <code>cP</code> within the <code>Q</code> command.</p>

<p>To see how I created the following example. Notice that the inferred handles are drawn in green, while our specified controls are still rendered in red.</p>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="vEOQJBM"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [SVG Path Quadratic Curve T Command [forked]](https://codepen.io/smashingmag/pen/vEOQJBM) by <a href="https://codepen.io/halfapx">Myriam</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/vEOQJBM">SVG Path Quadratic Curve T Command [forked]</a> by <a href="https://codepen.io/halfapx">Myriam</a>.</figcaption>
</figure>

<p>OK, so the top curve takes two <code>Q</code> commands, which means, in total, there are three control points. Using a separate control point to create the scallop makes sense, but the third control point is just a reflection of the second control point through the preceding point.</p>

<p>This is what the <code>T</code> command does. It infers control points by reflecting them through the end point of the preceding <code>Q</code> (or <code>T</code>) command. You can see how the system all links up in the animation below, where all I’ve manipulated is the position of the main points and the first control points. The inferred control points follow along.</p>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="WbvYENx"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [SVG Path Quadratic Bézier Spline T Command Visual [forked]](https://codepen.io/smashingmag/pen/WbvYENx) by <a href="https://codepen.io/halfapx">Myriam</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/WbvYENx">SVG Path Quadratic Bézier Spline T Command Visual [forked]</a> by <a href="https://codepen.io/halfapx">Myriam</a>.</figcaption>
</figure>

<p>The <code>q</code> and <code>t</code> commands also exist, so they will use relative coordinates.</p>

<p>Before I go on, if you do want to interact with a cubic curve, <a href="https://yqnn.github.io/svg-path-editor/">SVG Path Editor</a> allows you to edit all path commands very nicely.</p>

<h3 id="cubic-bézier-curves-c-and-s">Cubic Bézier Curves: <code>C</code> And <code>S</code></h3>

<p>Cubic Bézier curves work basically like quadratic ones, but instead of having one control point, they have two. This is probably the curve you are most familiar with.</p>

<p>The order is that you start with the first control point, then the second, and then the end point.</p>

<div class="break-out">
<pre><code class="language-javascript">const path = `M${p1.x} ${p1.y} C${cP1.x} ${cP1.y} ${cP2.x} ${cP2.y} ${p2.x} ${p2.y}`;
</code></pre>
</div>

<p>Let’s look at a visual to see it in action.</p>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="EajOvaL"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [SVG Path Cubic Bézier Curve Animation [forked]](https://codepen.io/smashingmag/pen/EajOvaL) by <a href="https://codepen.io/halfapx">Myriam</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/EajOvaL">SVG Path Cubic Bézier Curve Animation [forked]</a> by <a href="https://codepen.io/halfapx">Myriam</a>.</figcaption>
</figure>

<blockquote><strong>Cubic Bézier curves are contortionists.</strong><br />Unlike the quadratic curve, this one can curl up and form loops and take on completely different shapes than any other SVG element. It can split the filled area into two parts, while the quadratic curve can not.</blockquote>

<p>Just like with the <code>T</code> command, a reflecting command is available for cubic curves <code>S</code>.</p>

<p>When using it, we get the first control point through the reflection, while we can define the new end control point and then the end point. Like before, this requires a spline, so at least one preceding <code>C</code> (or <code>S</code>) command.</p>

<pre><code class="language-javascript">const path = `    
  M ${p0.x} ${p0.y}
  C ${c0.x} ${c0.y} ${c1.x} ${c1.y} ${p1.x} ${p1.y}
  S ${c2.x} ${c2.y} ${p2.x} ${p2.y}
`;
</code></pre>

<p>I created a living visual for that as well.</p>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="RNPqZPz"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [SVG Path Cubic Bézier Spline S Command Visual [forked]](https://codepen.io/smashingmag/pen/RNPqZPz) by <a href="https://codepen.io/halfapx">Myriam</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/RNPqZPz">SVG Path Cubic Bézier Spline S Command Visual [forked]</a> by <a href="https://codepen.io/halfapx">Myriam</a>.</figcaption>
</figure>

<blockquote><strong>When to use <code>T</code> and <code>S</code>:</strong><br />The big advantage of using these chaining reflecting commands is if you want to draw waves or just absolutely ensure that your spline connection is smooth.</blockquote>

<p>If you can’t use a reflection but want to have a nice, smooth connection, make sure your control points form a straight line. If you have a kink in the handles, your spline will get one, too.</p>

<div class="partners__lead-place"></div>

<h2 id="arcs-a-command">Arcs: <code>A</code> Command</h2>

<p>Finally, the last type of <code>path</code> command is to create arcs. Arcs are sections of circles or ellipses.</p>

<p>It’s my least favorite command because there are so many elements to it. But it is the secret to drawing a proper donut chart, so I have a bit of time spent with it under my belt.</p>

<p>Let’s look at it.</p>

<p>Like with any other <code>path</code> command, lowercase implies relative coordinates. So, just as there is an <code>A</code> command, there’s also an <code>a</code>.</p>

<p>So, an arc path looks like this:</p>

<div class="break-out">
<pre><code class="language-javascript">const path = `M${start.x} ${start.y} A${radius.x} ${radius.y} ${xAxisRotation} ${largeArcFlag} ${sweepFlag} ${end.x} ${end.y}`;
</code></pre>
</div>

<p>And what the heck are <code>xAxisRotation</code>, <code>largeArcFlag</code>, and <code>sweepFlag</code> supposed to be? In short:</p>

<ul>
<li><code>xAxisRotation</code> is the rotation of the underlying ellipse’s axes in degrees.</li>
<li><code>largeArcFlag</code> is a boolean value that determines if the arc is greater than 180°.</li>
<li><code>sweepFlag</code> is also a boolean and determines the arc direction, so does it go clockwise or counter-clockwise?</li>
</ul>

<p>To better understand these concepts, I created this visual.</p>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="GgJwvZR"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [SVG Path Arc Command Visuals [forked]](https://codepen.io/smashingmag/pen/GgJwvZR) by <a href="https://codepen.io/halfapx">Myriam</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/GgJwvZR">SVG Path Arc Command Visuals [forked]</a> by <a href="https://codepen.io/halfapx">Myriam</a>.</figcaption>
</figure>

<h3 id="radius-size">Radius Size</h3>

<p>You’ll notice in that CodePen that there are ellipses drawn for each command. In the top row, they are overlapping, while in the bottom row, they are stacked up. Both rows actually use the same <code>radius.x</code>  and <code>radius.y</code> values in their arc definitions, while the distance between the start and end points increases for the second row.</p>

<p>The reason why the stacking happens is that the radius size is only taken into consideration if the start and end points fit within the specified ellipse. That behavior surprised me, and thus, I dug into the specs and found the following information on how the arc works:</p>

<blockquote>“Arbitrary numerical values are permitted for all elliptical arc parameters (other than the boolean flags), but user agents must make the following adjustments for invalid values when rendering curves or calculating their geometry:<br /><br />If the endpoint (<strong>x</strong>, <strong>y</strong>) of the segment is identical to the current point (e.g., the endpoint of the previous segment), then this is equivalent to omitting the elliptical arc segment entirely.<br /><br />If either <strong>rx</strong> or <strong>ry</strong> is 0, then this arc is treated as a straight line segment (a “lineto”) joining the endpoints.<br /><br />If either <strong>rx</strong> or <strong>ry</strong> have negative signs, these are dropped; the absolute value is used instead.<br /><br />If <strong>rx</strong>, <strong>ry</strong> and <strong>x-axis-rotation</strong> are such that there is no solution (basically, the ellipse is not big enough to reach from the current point to the new endpoint) then the ellipse is scaled up uniformly until there is exactly one solution (until the ellipse is just big enough).<br /><br />See the appendix section <a href="https://svgwg.org/svg2-draft/implnote.html#ArcCorrectionOutOfRangeRadii">Correction of out-of-range radii</a> for the mathematical formula for this scaling operation.”<br /><br />&mdash; <a href="https://svgwg.org/svg2-draft/paths.html#ArcOutOfRangeParameters">9.5.1 Out-of-range elliptical arc parameters</a></blockquote>

<p>So, really, that stacking is just nice and graceful error-handling and not how it was intended. Because the top row is how arcs should be used.</p>

<blockquote>When plugging in logical values, the underlying ellipses and the two points give us four drawing options for how we could connect the two points along an elliptical path. That’s what the boolean values are for.</blockquote>

<h3 id="xaxisrotation"><code>xAxisRotation</code></h3>

<p>Before we get to the booleans, the crosshatch pattern shows the <code>xAxisrotation</code>. The ellipse is rotated around its center, with the degree value being in relation to the x-direction of the SVG.</p>

<p>So, if you work with a circular ellipse, the rotation won’t have any effect on the arc (except if you use it in a pattern like I did there).</p>

<h3 id="sweep-flag">Sweep Flag</h3>

<p>Notice the little arrow marker to show the arc drawing direction. If the value is 0, the arc is drawn clockwise. If the value is 1, the arc is drawn counterclockwise.</p>

<h3 id="large-arc-flag">Large Arc Flag</h3>

<p>The large Arc Flag tells the path if you want the smaller or the larger arc from the ellipse. If we have a scaled case, we get exactly 180° of our ellipse.</p>

<blockquote>Arcs usually require a lot more annoying circular number-wrangling than I am happy doing (As soon as radians come to play, I tend to spiral into rabbit holes where I have to relearn too much math I happily forget.)<br /><br />They are more reliant on values being related to each other for the outcome to be as expected and there’s just so much information going in.<br /><br />But &mdash; and that’s a bit but &mdash; arcs are wonderfully powerful!</blockquote>

<div class="partners__lead-place"></div>

<h2 id="conclusion">Conclusion</h2>

<p>Alright, that was a lot! However, I do hope that you are starting to see how <code>path</code> commands can be helpful. I find them extremely useful to illustrate data.</p>

<p>Once you know how easy it is to set up stuff like grids, boxes, and curves, it doesn’t take many more steps to create visualizations that are a bit more unique than what the standard data visualization libraries offer.</p>

<blockquote>With everything you’ve learned in this series of articles, you’re basically fully equipped to render all different types of charts &mdash; or other types of visualizations.</blockquote>

<p>Like, how about visualizing the underlying cubic-bezier of something like <code>transition-timing-function: ease;</code> in CSS? That’s the thing I made to figure out how I could turn those transition-timing-functions into something an <code>&lt;animate&gt;</code> tag understands.</p>

<figure class="break-out">
	<p data-height="480"
	data-theme-id="light"
	data-slug-hash="gbpQxgp"
	data-user="smashingmag"
	data-default-tab="result"
	class="codepen">See the Pen [CSS Cubic Beziers as SVG Animations &amp; CSS Transition Comparisons [forked]](https://codepen.io/smashingmag/pen/gbpQxgp) by <a href="https://codepen.io/halfapx">Myriam</a>.</p>
	<figcaption>See the Pen <a href="https://codepen.io/smashingmag/pen/gbpQxgp">CSS Cubic Beziers as SVG Animations &amp; CSS Transition Comparisons [forked]</a> by <a href="https://codepen.io/halfapx">Myriam</a>.</figcaption>
</figure>

<p>SVG is fun and quirky, and the <code>path</code> element may be the holder of the most overwhelming string of symbols you’ve ever laid eyes on during code inspection. However, if you take the time to understand the underlying logic, it all transforms into one beautifully simple and extremely powerful syntax.</p>

<p>I hope with this pair of <code>path</code> decoding articles, I managed to expose the underlying mechanics of how path plots work. If you want even more resources that don’t require you to dive through specs, try the <a href="https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths">MDN tutorial about paths</a>. It’s short and compact, and was the main resource for me to learn all of this.</p>

<p>However, since I wrote my deep dive on the topic, I stumbled into the beautiful <a href="https://svg-tutorial.com">svg-tutorial.com</a>, which does a wonderful job visualizing SVG coding as a whole but mostly features my favorite arc visual of them all in the <a href="https://svg-tutorial.com/editor/arc">Arc Editor</a>. And if you have a path that you’d like properly decoded without having to store all of the information in these two articles, there’s <a href="https://svg-path-visualizer.netlify.app/">SVG Path Visualizer</a>, which breaks down path information super nicely.</p>

<p>And now: Go forth and have fun playing in the matrix.</p>

<div class="signature">
  <img src="https://www.smashingmagazine.com/images/logo/logo--red.png" alt="Smashing Editorial" width="35" height="46" loading="lazy" decoding="async" />
  <span>(gg, yk)</span>
</div>


              </article>
            </body>
          </html>
        ]]></content:encoded></item><item><author>Victor Ayomipo</author><title>CSS Cascade Layers Vs. BEM Vs. Utility Classes: Specificity Control</title><link>https://www.smashingmagazine.com/2025/06/css-cascade-layers-bem-utility-classes-specificity-control/</link><pubDate>Thu, 19 Jun 2025 15:00:00 +0000</pubDate><guid>https://www.smashingmagazine.com/2025/06/css-cascade-layers-bem-utility-classes-specificity-control/</guid><description>CSS can be unpredictable — and specificity is often the culprit. Victor Ayomipo breaks down how and why your styles might not behave as expected, and why understanding specificity is better than relying on &lt;code>!important&lt;/code> flags.</description><content:encoded><![CDATA[
          <html>
            <head>
              <meta charset="utf-8">
              <link rel="canonical" href="https://www.smashingmagazine.com/2025/06/css-cascade-layers-bem-utility-classes-specificity-control/" />
              <title>CSS Cascade Layers Vs. BEM Vs. Utility Classes: Specificity Control</title>
            </head>
            <body>
              <article>
                <header>
                  <h1>CSS Cascade Layers Vs. BEM Vs. Utility Classes: Specificity Control</h1>
                  
                    
                    <address>Victor Ayomipo</address>
                  
                  <time datetime="2025-06-19T15:00:00&#43;00:00" class="op-published">2025-06-19T15:00:00+00:00</time>
                  <time datetime="2025-06-19T15:00:00&#43;00:00" class="op-modified">2026-05-12T16:11:33+00:00</time>
                </header>
                
                

<p>CSS is wild, really wild. And tricky. But let’s talk specifically about <strong>specificity</strong>.</p>

<p>When writing CSS, it’s close to impossible that you haven’t faced the frustration of styles not applying as expected &mdash; that’s specificity. You applied a style, it worked, and later, you try to override it with a different style and… nothing, it just ignores you. Again, specificity.</p>

<p>Sure, there’s the option of resorting to <code>!important</code> flags, but like all developers before us, it’s always <a href="https://cssguidelin.es/#important">risky and discouraged</a>. It’s way better to fully understand specificity than go down that route because otherwise you wind up fighting your own important styles.</p>

<h2 id="specificity-101">Specificity 101</h2>

<p>Lots of developers understand the concept of specificity in different ways.</p>

<blockquote>The core idea of specificity is that the CSS Cascade algorithm used by browsers determines which style declaration is applied when two or more rules match the same element.</blockquote>

<p>Think about it. As a project expands, so do the specificity challenges. Let’s say Developer A adds <code>.cart-button</code>, then maybe the button style looks good to be used on the sidebar, but with a little tweak. Then, later, Developer B adds <code>.cart-button .sidebar</code>, and from there, any future changes applied to <code>.cart-button</code> might get overridden by <code>.cart-button .sidebar</code>, and just like that, the specificity war begins.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/1-specificity-tension.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="500"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/1-specificity-tension.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/1-specificity-tension.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/1-specificity-tension.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/1-specificity-tension.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/1-specificity-tension.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/1-specificity-tension.png"
			
			sizes="100vw"
			alt="Specifity tension represented by a pile of different elements"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/1-specificity-tension.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>I’ve written CSS long enough to witness different strategies that developers have used to manage the specificity battles that come with CSS.</p>

<pre><code class="language-css">/&#42; Traditional approach &#42;/
#header .nav li a.active { color: blue; }

/&#42; BEM approach &#42;/
.header__nav-item--active { color: blue; }

/&#42; Utility classes approach &#42;/
.text-blue { color: blue; }

/&#42; Cascade Layers approach &#42;/
@layer components {
  .nav-link.active { color: blue; }
}
</code></pre>

<p>All these methods reflect different strategies on how to control or at least maintain CSS specificity:</p>

<ul>
<li><strong>BEM</strong>: tries to simplify specificity by being explicit.</li>
<li><strong>Utility-first CSS</strong>: tries to bypass specificity by keeping it all atomic.</li>
<li><strong>CSS Cascade Layers</strong>: manage specificity by organizing styles in layered groups.</li>
</ul>

<p>We’re going to put all three side by side and look at how they handle specificity.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/2-different-strategies-control-css-specificity.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="500"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/2-different-strategies-control-css-specificity.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/2-different-strategies-control-css-specificity.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/2-different-strategies-control-css-specificity.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/2-different-strategies-control-css-specificity.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/2-different-strategies-control-css-specificity.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/2-different-strategies-control-css-specificity.png"
			
			sizes="100vw"
			alt="A chart which ilustrates different strategies on how to control or at least maintain CSS specificity"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/2-different-strategies-control-css-specificity.png'>Large preview</a>)
    </figcaption>
  
</figure>

<div data-audience="non-subscriber" data-remove="true" class="feature-panel-container">

<aside class="feature-panel" style="">
<div class="feature-panel-left-col">

<div class="feature-panel-description"><p>Meet <strong><a data-instant href="https://www.smashingconf.com/online-workshops/">Smashing Workshops</a></strong> on <strong>front-end, design &amp; UX</strong>, with practical takeaways, live sessions, <strong>video recordings</strong> and a friendly Q&amp;A. With Brad Frost, Stéph Walter and <a href="https://smashingconf.com/online-workshops/workshops">so many others</a>.</p>
<a data-instant href="smashing-workshops" class="btn btn--green btn--large" style="">Jump to the workshops&nbsp;↬</a></div>
</div>
<div class="feature-panel-right-col"><a data-instant href="smashing-workshops" class="feature-panel-image-link">
<div class="feature-panel-image">
<img
    loading="lazy"
    decoding="async"
    class="feature-panel-image-img"
    src="/images/smashing-cat/cat-scubadiving-panel.svg"
    alt="Feature Panel"
    width="257"
    height="355"
/>

</div>
</a>
</div>
</aside>
</div>

<h2 id="my-relationship-with-specificity">My Relationship With Specificity</h2>

<p>I actually used to think that I got the whole picture of CSS specificity. Like the usual inline greater than ID greater than class greater than tag. But, reading <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascade/Cascade">the MDN docs on how the CSS Cascade truly works</a> was an eye-opener.</p>

<p>There’s a code I worked on in an old codebase provided by a client, which looked something like this:</p>

<pre><code class="language-css">/&#42; Legacy code &#42;/
&#35;main-content .product-grid button.add-to-cart {
  background-color: #3a86ff;
  color: white;
  padding: 10px 15px;
  border-radius: 4px;
}

/&#42; 100 lines of other code here &#42;/

/&#42; My new CSS &#42;/
.btn-primary {
  background-color: #4361ee; /&#42; New brand color &#42;/
  color: white;
  padding: 12px 20px;
  border-radius: 4px;
  box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
</code></pre>

<p>Looking at this code, no way that the <code>.btn-primary</code> class stands a chance against whatever specificity chain of selectors was previously written. As far as specification goes, CSS gives the first selector a specificity score of <code>1, 2, 1</code>: one point for the ID, two points for the two classes, and one point for the element selector. Meanwhile, the second selector is scored as <code>0, 1, 0</code> since it only consists of a single class selector.</p>

<p>Sure, I had some options:</p>

<ul>
<li>I could <strong>use <code>!important</code> on the properties</strong> in <code>.btn-primary</code> to override the ones declared in the stronger selector, but the moment that happens, be prepared to use it everywhere. So, I’d rather avoid it.</li>
<li>I could try <strong>going more specific</strong>, but personally, that’s just being cruel to the next developer (who might even be me).</li>
<li>I could <strong>change the styles of the existing code</strong>, but that’s adding to the specificity problem:</li>
</ul>

<pre><code class="language-css">&#35;main-content .product-grid .btn-primary {
  /&#42; edit styles directly &#42;/
}
</code></pre>

<p>Eventually, I ended up writing the whole CSS from scratch.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/3-legacy-modern-button.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="500"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/3-legacy-modern-button.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/3-legacy-modern-button.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/3-legacy-modern-button.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/3-legacy-modern-button.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/3-legacy-modern-button.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/3-legacy-modern-button.png"
			
			sizes="100vw"
			alt="Legacy button vs modern button"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/3-legacy-modern-button.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>When <em>nesting</em> was introduced, I tried it to control specificity that way:</p>

<div class="break-out">
<pre><code class="language-css">.profile-widget {
  // ... other styles
  .header {
    // ... header styles
    .user-avatar {
      border: 2px solid blue;
      &.is-admin {
        border-color: gold; // This becomes .profile-widget .header .user-avatar.is-admin
      }
    }
  }
}
</code></pre>
</div>

<p>And just like that, I have unintentionally created high-specificity rules. That’s how easily and naturally we can drift toward specificity complexities.</p>

<p>So, to save myself a lot of these issues, I have one principle I always abide by: <a href="https://css-tricks.com/strategies-keeping-css-specificity-low/"><strong>keep specificity as low as possible</strong></a>. And if the selector complexity is becoming a complex chain, I rethink the whole thing.</p>

<div class="partners__lead-place"></div>

<h2 id="bem-the-og-system">BEM: The OG System</h2>

<p>The Block-Element-Modifier (BEM, for short) has been around the block (pun intended) for a long time. It is a methodological system for writing CSS that forces you to make every style hierarchy explicit.</p>

<pre><code class="language-css">/&#42; Block &#42;/
.panel {}

/&#42; Element that depends on the Block &#42;/
.panel&#95;&#95;header {}
.panel&#95;&#95;content {}
.panel&#95;&#95;footer {}

/&#42; Modifier that changes the style of the Block &#42;/
.panel--highlighted {}
.panel&#95;&#95;button--secondary {}
</code></pre>

<p>When I first experienced BEM, I thought it was amazing, despite contrary opinions that it looked ugly. I had no problems with the double hyphens or underscores because they made my CSS predictable and simplified.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/4-bem.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="500"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/4-bem.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/4-bem.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/4-bem.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/4-bem.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/4-bem.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/4-bem.png"
			
			sizes="100vw"
			alt="Illustration for BEM methodological system"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/4-bem.png'>Large preview</a>)
    </figcaption>
  
</figure>

<h3 id="how-bem-handles-specificity">How BEM Handles Specificity</h3>

<p>Take a look at these examples. Without BEM:</p>

<pre><code class="language-css">/&#42; Specificity: 0, 3, 0 &#42;/
.site-header .main-nav .nav-link {
  color: &#35;472EFE;
  text-decoration: none;
}

/&#42; Specificity: 0, 2, 0 &#42;/
.nav-link.special {
  color: &#35;FF5733;
}
</code></pre>

<p>With BEM:</p>

<pre><code class="language-css">/&#42; Specificity: 0, 1, 0 &#42;/
.main-nav&#95;&#95;link {
  color: &#35;472EFE;
  text-decoration: none;
}

/&#42; Specificity: 0, 1, 0 &#42;/
.main-nav&#95;&#95;link--special {
  color: &#35;FF5733;
}
</code></pre>

<p>You see how BEM makes the code look predictable as all selectors are created equal, thus making the code easier to maintain and extend. And if I want to add a button to <code>.main-nav</code>, I just add <code>.main-nav__btn</code>, and if I need a disabled button (modifier), <code>.main-nav__btn--disabled</code>. Specificity is low, as I don’t have to increase it or fight the cascade; I just write a new class.</p>

<p>BEM’s naming principle made sure components lived in isolation, which, for a part of CSS, the specificity part, it worked, i.e, <code>.card__title</code> class will never accidentally clash with a <code>.menu__title</code> class.</p>

<h3 id="where-bem-falls-short">Where BEM Falls Short</h3>

<p>I like the idea of BEM, but it is not perfect, and a lot of people noticed it:</p>

<ul>
<li>The class names can get <em>really</em> long.</li>
</ul>

<div class="break-out">
<pre><code class="language-html">&lt;div class="product-carousel&#95;&#95;slide--featured product-carousel&#95;&#95;slide--on-sale"&gt;
  &lt;!-- yikes --&gt;
&lt;/div&gt;
</code></pre>
</div>

<ul>
<li><strong>Reusability might not be prioritized</strong>, which somewhat contradicts the native CSS ideology. Should a button inside a card be <code>.card__button</code> or reuse a global <code>.button</code> class? With the former, styles are being duplicated, and with the latter, the BEM strict model is being broken.</li>
<li>One of the core pains in software development starts becoming a reality &mdash; <strong>naming things</strong>. <a href="https://css-tricks.com/naming-things-is-only-getting-harder/">I’m sure you know the frustration of that already.</a></li>
</ul>

<p>BEM is good, but sometimes you may need to be flexible with it. A <strong>hybrid system</strong> (maybe using BEM for core components but simpler classes elsewhere) can still keep specificity as low as needed.</p>

<pre><code class="language-css">/&#42; Base button without BEM &#42;/
.button {
  /&#42; Button styles &#42;/
}

/&#42; Component-specific button with BEM &#42;/
.card&#95;&#95;footer .button {
  /&#42; Minor overrides &#42;/
}
</code></pre>

<h2 id="utility-classes-specificity-by-avoidance">Utility Classes: Specificity By Avoidance</h2>

<p>This is also called <a href="https://css-tricks.com/lets-define-exactly-atomic-css/">Atomic CSS</a>. And in its entirety, it <em>avoids specificity.</em></p>

<div class="break-out">
<pre><code class="language-html">&lt;button class="bg-red-300 hover:bg-red-500 text-white py-2 px-4 rounded"&gt;
  A button
&lt;/button&gt;
</code></pre>
</div>

<blockquote>The idea behind utility-first classes is that every utility class has the same specificity, which is one class selector. Each class is a tiny CSS property with a single purpose.</blockquote>

<p><code>p-2</code>? Padding, nothing more. <code>text-red</code>? Color red for text. <code>text-center</code>? Text alignment. It’s like how LEGOs work, but for styling. You stack classes on top of each other until you get your desired appearance.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/5-specificity-by-avoidance.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="500"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/5-specificity-by-avoidance.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/5-specificity-by-avoidance.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/5-specificity-by-avoidance.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/5-specificity-by-avoidance.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/5-specificity-by-avoidance.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/5-specificity-by-avoidance.png"
			
			sizes="100vw"
			alt="An illustration with a title: Avoiding specifity - one utility at a time"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/css-cascade-layers-bem-utility-classes-specificity-control/5-specificity-by-avoidance.png'>Large preview</a>)
    </figcaption>
  
</figure>

<h3 id="how-utility-classes-handle-specificity">How Utility Classes Handle Specificity</h3>

<p>Utility classes do not solve specificity, but rather, they take the BEM ideology of low specificity to the extreme. Almost all utility classes have the same lowest possible specificity level of (<code>0</code>, <code>1</code>, <code>0</code>). And because of this, overrides become easy; if more padding is needed, bump <code>.p-2</code> to <code>.p-4</code>.</p>

<p>Another example:</p>

<pre><code class="language-html">&lt;button class="bg-orange-300 hover:bg-orange-700"&gt;
  This can be hovered
&lt;/button&gt;
</code></pre>

<p>If another class, <code>hover:bg-red-500</code>, is added, the order matters for CSS to determine which to use. So, even though the utility classes avoid specificity, the other parts of the CSS Cascade come in, which is the order of appearance, with the last matching selector declared being the winner.</p>

<h3 id="utility-class-trade-offs">Utility Class Trade-Offs</h3>

<p>The most common issue with utility classes is that <strong>they make the code look ugly</strong>. And frankly, I agree. But being able to picture what a component looks like without seeing it rendered is just priceless.</p>

<p>There’s also the argument of reusability, that you repeat yourself every single time. But once one finds a repetition happening, just turn that part into a reusable component. It also has its genuine <strong>limitations</strong> when it comes to specificity:</p>

<ul>
<li>If your brand color changes, which is a global change, and you’re deep in the codebase, you can’t just change one and have others follow like native CSS.</li>
<li>The parent-child relationship that happens naturally in native CSS is out the window due to how atomic utility classes behave.</li>
<li>Some argue the HTML part should be left as markup and the CSS part for styling. Because now, there’s more markup to scan, and if you decide to clean up:</li>
</ul>

<div class="break-out">
<pre><code class="language-html">&lt;!-- Too long --&gt;
&lt;div class="p-4 bg-yellow-100 border border-yellow-300 text-yellow-800 rounded"&gt;

&lt;!-- Better? --&gt;
&lt;div class="alert-warning"&gt;
</code></pre>
</div>

<p>Just like that, we’ve ended up writing CSS. Circle of life.</p>

<p>In my experience with utility classes, they work best for:</p>

<ul>
<li><strong>Speed</strong><br />
Writing the markup, styling it, and seeing the result swiftly.</li>
<li><strong>Predictability</strong><br />
A utility class does exactly what it says it does.</li>
</ul>

<h2 id="cascade-layers-specificity-by-design">Cascade Layers: Specificity By Design</h2>

<p>Now, this is where it gets interesting. BEM offers structure, utility classes gain speed, and CSS Cascade Layers give us something paramount: <strong>control</strong>.</p>

<p>Anyways, Cascade Layers (<code>@layers</code>) groups styles and declares what order the groups should be, regardless of the specificity scores of those rules.</p>

<p>Looking at a set of independent rulesets:</p>

<pre><code class="language-css">button {
  background-color: orange; /&#42; Specificity: 0, 0, 1 &#42;/
}

.button {
  background-color: blue; //&#42; Specificity: 0, 1, 0&#42;/
}

#button {
  background-color: red; /&#42; Specificity: 1, 0, 0 &#42;/
}

/&#42; No matter what, the button is red &#42;/
</code></pre>

<p>But with <code>@layer</code>, let’s say, I want to prioritize the <code>.button</code> class selector. I can shape how the specificity order should go:</p>

<pre><code class="language-css">@layer utilities, defaults, components;

@layer defaults {
  button {
    background-color: orange; /&#42; Specificity: 0, 0, 1 &#42;/
  }
}

@layer components {
  .button {
    background-color: blue; //&#42; Specificity: 0, 1, 0&#42;/
  }
}

@layer utilities {
  #button {
    background-color: red; /&#42; Specificity: 1, 0, 0 &#42;/
  }
}
</code></pre>

<p>Due to how <code>@layer</code> works, <code>.button</code> would win because the <code>components</code> layer is the highest priority, even though <code>#button</code> has higher specificity. Thus, before CSS could even check the usual specificity rules, the layer order would first be respected.</p>

<p>You just have to respect the folks over at W3C, because now one can purposely override an ID selector with a simple class, without even using <code>!important</code>. Fascinating.</p>

<h3 id="cascade-layers-nuances">Cascade Layers Nuances</h3>

<p>Here are some things that are worth calling out when we’re talking about CSS Cascade Layers:</p>

<ul>
<li>Specificity is still part of the game.</li>
<li><code>!important</code> acts differently than expected in <code>@layer</code> (they work in reverse!).</li>
<li><code>@layers</code> aren’t selector-specific but rather style-property-specific.</li>
</ul>

<div class="break-out">
<pre><code class="language-css">@layer base {
  .button {
    background-color: blue;
    color: white;
  }
}

@layer theme {
  .button {
    background-color: red;
    /&#42; No color property here, so white from base layer still applies &#42;/
  }
}
</code></pre>
</div>

<ul>
<li><code>@layer</code> can easily be abused. I’m sure there’s a developer out there with over 20+ layer declarations that’s grown into a monstrosity.</li>
</ul>

<h3 id="comparing-all-three">Comparing All Three</h3>

<p>Now, for the TL;DR folks out there, here’s a side-by-side comparison of the three: BEM, utility classes, and CSS Cascade Layers.</p>

<table class="tablesaw break-out">
    <thead>
        <tr>
            <th>Feature</th>
            <th>BEM</th>
      <th>Utility Classes</th>
      <th>Cascade Layers</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Core Idea</td>
            <td>Namespace components</td>
      <td>Single purpose classes</td>
      <td>Control cascade order</td>
        </tr>
        <tr>
            <td>Specificity Control</td>
            <td>Low and flat</td>
            <td>Avoids entirely</td>
            <td>Absolute control due to Layer supremacy</td>
        </tr>
        <tr>
            <td>Code Readability</td>
            <td>Clear structure due to naming</td>
            <td>Unclear if unfamiliar with the class names</td>
            <td>Clear if layer structure is followed</td>
        </tr>
        <tr>
            <td>HTML Verbosity</td>
            <td>Moderate class names (can get long)</td>
            <td>Many small classes that adds up quickly</td>
            <td>No direct impact, stays only in CSS</td>
        </tr>
        <tr>
            <td>CSS Organization</td>
            <td>By component</td>
            <td>By property</td>
            <td>By priority order</td>
        </tr>
        <tr>
            <td>Learning Curve</td>
            <td>Requires understanding conventions</td>
            <td>Requires knowing the utility names</td>
            <td>Easy to pick up, but requires a deep understanding of CSS</td>
        </tr>
        <tr>
            <td>Tools Dependency</td>
            <td>Pure CSS</td>
            <td>Often depends of third-party e.g Tailwind</td>
            <td>Native CSS</td>
        </tr>
        <tr>
            <td>Refactoring Ease</td>
            <td>High</td>
            <td>Medium</td>
            <td>Low</td>
        </tr>
        <tr>
            <td>Best Use Case</td>
            <td>Design Systems</td>
            <td>Fast builds</td>
            <td>Legacy code or third-party codes that need overrides</td>
        </tr>
        <tr>
            <td>Browser Support</td>
            <td>All</td>
            <td>All</td>
            <td>All (except IE)</td>
        </tr>
    </tbody>
</table>

<p>Among the three, each has its sweet spot:</p>

<ul>
<li><strong>BEM</strong> is best when:

<ul>
<li>There’s a clear design system that needs to be consistent,</li>
<li>There’s a team with different philosophies about CSS (BEM can be the middle ground), and</li>
<li>Styles are less likely to leak between components.</li>
</ul></li>
<li><strong>Utility classes</strong> work best when:

<ul>
<li>You need to build fast, like prototypes or MVPs, and</li>
<li>Using a component-based JavaScript framework like React.</li>
</ul></li>
<li><strong>Cascade Layers</strong> are most effective when:

<ul>
<li>Working on legacy codebases where you need full specificity control,</li>
<li>You need to integrate third-party libraries or styles from different sources, and</li>
<li>Working on a large, complex application or projects with long-term maintenance.</li>
</ul></li>
</ul>

<p>If I had to choose or rank them, I’d go for utility classes with Cascade Layers over using BEM. But that’s just me!</p>

<div class="partners__lead-place"></div>

<h2 id="where-they-intersect-how-they-can-work-together">Where They Intersect (How They Can Work Together)</h2>

<p>Among the three, Cascade Layers should be seen as an orchestrator, as it can work with the other two strategies. <code>@layer</code> is a fundamental tenet of the CSS Cascade’s architecture, unlike BEM and utility classes, which are methodologies for controlling the Cascade’s behavior.</p>

<pre><code class="language-css">/&#42; Cascade Layers + BEM &#42;/
@layer components {
  .card&#95;&#95;title {
    font-size: 1.5rem;
    font-weight: bold;
  }
}

/&#42; Cascade Layers + Utility Classes &#42;/
@layer utilities {
  .text-xl {
    font-size: 1.25rem;
  }
  .font-bold {
    font-weight: 700;
  }
}
</code></pre>

<p>On the other hand, using BEM with utility classes would just end up clashing:</p>

<div class="break-out">
<pre><code class="language-javascript">&lt;!-- This feels wrong --&gt;
&lt;div class="card__container p-4 flex items-center"&gt;
  &lt;p class="card__title text-xl font-bold"&gt;Something seems wrong&lt;/p&gt;
&lt;/div&gt;
</code></pre>
</div>

<p>I’m putting all my cards on the table: I’m a utility-first developer. And most utility class frameworks use <code>@layer</code> behind the scenes (e.g., <a href="https://tailwindcss.com/blog/tailwindcss-v4#designed-for-the-modern-web">Tailwind</a>). So, those two are already together in the bag.</p>

<p>But, do I dislike BEM? Not at all! I’ve used it a lot and still would, if necessary. I just find naming things to be an exhausting exercise.</p>

<p>That said, we’re all different, and you might have opposing thoughts about what you think feels best. It truly doesn’t matter, and that’s the beauty of this web development space. <em>Multiple routes can lead to the same destination</em>.</p>

<h2 id="conclusion">Conclusion</h2>

<p>So, when it comes to comparing BEM, utility classes, and CSS Cascade Layers, is there a true “winning” approach for controlling specificity in the Cascade?</p>

<p>First of all, CSS Cascade Layers are arguably the most powerful CSS feature that we’ve gotten in years. They shouldn’t be confused with BEM or utility classes, which are strategies rather than part of the CSS feature set.</p>

<p>That’s why I like the idea of combining either BEM with Cascade Layers or utility classes with Cascade Layers. Either way, the idea is to <strong>keep specificity low and leverage Cascade Layers to set priorities on those styles</strong>.</p>

<div class="signature">
  <img src="https://www.smashingmagazine.com/images/logo/logo--red.png" alt="Smashing Editorial" width="35" height="46" loading="lazy" decoding="async" />
  <span>(gg, yk)</span>
</div>


              </article>
            </body>
          </html>
        ]]></content:encoded></item><item><author>Eric Bailey</author><title>What I Wish Someone Told Me When I Was Getting Into ARIA</title><link>https://www.smashingmagazine.com/2025/06/what-i-wish-someone-told-me-aria/</link><pubDate>Mon, 16 Jun 2025 13:00:00 +0000</pubDate><guid>https://www.smashingmagazine.com/2025/06/what-i-wish-someone-told-me-aria/</guid><description>&lt;a href="https://www.w3.org/WAI/standards-guidelines/aria/">Accessible Rich Internet Applications (ARIA)&lt;/a> is an inevitability when working on web accessibility. That said, it’s everyone’s first time learning about ARIA at some point.</description><content:encoded><![CDATA[
          <html>
            <head>
              <meta charset="utf-8">
              <link rel="canonical" href="https://www.smashingmagazine.com/2025/06/what-i-wish-someone-told-me-aria/" />
              <title>What I Wish Someone Told Me When I Was Getting Into ARIA</title>
            </head>
            <body>
              <article>
                <header>
                  <h1>What I Wish Someone Told Me When I Was Getting Into ARIA</h1>
                  
                    
                    <address>Eric Bailey</address>
                  
                  <time datetime="2025-06-16T13:00:00&#43;00:00" class="op-published">2025-06-16T13:00:00+00:00</time>
                  <time datetime="2025-06-16T13:00:00&#43;00:00" class="op-modified">2026-05-12T16:11:33+00:00</time>
                </header>
                
                

<p>If you haven’t encountered ARIA before, great! It’s a chance to learn something new and exciting. If you have heard of ARIA before, this might help you better understand it or maybe even teach you something new!</p>

<p>These are all things I wish someone had told me when I was getting started on my web accessibility journey. This post will:</p>

<ul>
<li>Provide a mindset for <strong>how to approach ARIA</strong> as a concept,</li>
<li><strong>Debunk some common misconceptions</strong>, and</li>
<li><strong>Provide some guiding thoughts</strong> to help you better understand and work with it.</li>
</ul>

<p>It is my hope that in doing so, this post will help make an oft-overlooked yet vital corner of web design and development easier to approach.</p>

<h2 id="what-this-post-is-not">What This Post Is Not</h2>

<p>This <strong>is not</strong> a recipe book for how to use ARIA to build accessible websites and web apps. It is also not a guide for how to remediate an inaccessible experience. <strong>A lot of accessibility work is highly contextual</strong>. I do not know the specific needs of your project or organization, so trying to give advice here could easily do more harm than good.</p>

<p>Instead, think of this post as a “know before you go” guide. I’m hoping to give you a good headspace to approach ARIA, as well as highlight things to watch out for when you undertake your journey. So, with that out of the way, let’s dive in!</p>

<div data-audience="non-subscriber" data-remove="true" class="feature-panel-container">

<aside class="feature-panel" style="">
<div class="feature-panel-left-col">

<div class="feature-panel-description"><p>Meet <strong><a data-instant href="/printed-books/typescript-in-50-lessons/">“TypeScript in 50 Lessons”</a></strong>, our shiny new guide to TypeScript. With detailed <strong>code walkthroughs</strong>, hands-on examples and common gotchas. For developers who know enough <strong>JavaScript</strong> to be dangerous.</p>
<a data-instant href="/printed-books/typescript-in-50-lessons/" class="btn btn--green btn--large" style="">Jump to table of contents&nbsp;↬</a></div>
</div>
<div class="feature-panel-right-col"><a data-instant href="/printed-books/typescript-in-50-lessons/" class="feature-panel-image-link">
<div class="feature-panel-image"><picture><source type="image/avif" srcSet="https://archive.smashing.media/assets/344dbf88-fdf9-42bb-adb4-46f01eedd629/2732dfe9-e1ee-41c3-871a-6252aeda741c/typescript-panel.avif" />
<img
    loading="lazy"
    decoding="async"
    class="feature-panel-image-img"
    src="https://archive.smashing.media/assets/344dbf88-fdf9-42bb-adb4-46f01eedd629/c2f2c6d6-4e85-449a-99f5-58bd053bc846/typescript-shop-cover-opt.png"
    alt="Feature Panel"
    width="481"
    height="698"
/>
</picture>
</div>
</a>
</div>
</aside>
</div>

<h2 id="so-what-is-aria">So, What Is ARIA?</h2>

<blockquote>ARIA is what you turn to if there is not a native HTML element or attribute that is better suited for the job of communicating interactivity, purpose, and state.</blockquote>

<p>Think of it like a spice that you sprinkle into your markup to enhance things.</p>

<p>Adding ARIA to your HTML markup is a way of providing additional information to a website or web app for <a href="https://webaim.org/articles/visual/blind#screenreaders">screen readers</a> and <a href="https://webaim.org/articles/motor/assistive#voicerecognition">voice control software</a>.</p>

<ul>
<li><strong>Interactivity</strong> means the content can be activated or manipulated. An example of this is navigating to a link’s destination.</li>
<li><strong>Purpose</strong> means what something is used for. An example of this is a text input used to collect someone’s name.</li>
<li><strong>State</strong> means the current status content has been placed in and controlled by <a href="https://www.w3.org/TR/wai-aria/#introstates">states, properties, and values</a>. An example of this is an accordion panel ​​that can either be expanded or collapsed.</li>
</ul>

<p>Here is an illustration to help communicate what I mean by this:</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/1-interactivity-purpose-state.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="244"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/1-interactivity-purpose-state.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/1-interactivity-purpose-state.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/1-interactivity-purpose-state.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/1-interactivity-purpose-state.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/1-interactivity-purpose-state.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/1-interactivity-purpose-state.png"
			
			sizes="100vw"
			alt="Three panels, showing a pressed-in mute button, its underlying HTML code, and three labels for “Interactivity,” “Purpose,” and “State.” The button element uses the “Interactivity” label. A declaration of aria-pressed equals true uses the “State” label. And finally, the button’s string value of “Mute” uses the “Purpose” label. The button’s HTML also uses a visually hidden CSS class to hide the string, then a decorative SVG icon to show a speaker mute icon."
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/1-interactivity-purpose-state.png'>Large preview</a>)
    </figcaption>
  
</figure>

<ul>
<li>The presence of <a href="https://html.spec.whatwg.org/multipage/form-elements.html#the-button-element">HTML’s <code>button</code> element</a> will instruct assistive technology to report it as a button, letting someone know that it can be activated to perform a predefined action.</li>
<li>The presence of the text string “Mute” will be reported by assistive technology to clue the person into what the button is used for.</li>
<li>The presence of <a href="https://w3c.github.io/aria/#aria-pressed"><code>aria-pressed=&quot;true&quot;</code></a> means that someone or something has previously activated the button, and it is now in a “pushed in” state that sustains its action.</li>
</ul>

<p>This overall pattern will let people who use assistive technology know:</p>

<ol>
<li>If something is interactive,</li>
<li>What kind of interactive behavior it performs, and</li>
<li>Its <a href="https://w3c.github.io/aria/#host_general_attrs">current state</a>.</li>
</ol>

<h2 id="aria-s-history">ARIA’s History</h2>

<p>ARIA has been around for a long time, with <a href="https://www.w3.org/TR/2006/WD-aria-role-20060926/">the first version published on September 26th, 2006</a>.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/2-browser.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="592"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/2-browser.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/2-browser.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/2-browser.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/2-browser.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/2-browser.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/2-browser.png"
			
			sizes="100vw"
			alt="The Roles for Accessible Rich Internet Applications (WAI-ARIA Roles) specification, loaded in a copy of Internet Explorer 7."
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/2-browser.png'>Large preview</a>)
    </figcaption>
  
</figure>

<blockquote class="pull-quote">
  <p>
    <a class="pull-quote__link" aria-label="Share on Twitter" href="https://twitter.com/share?text=%0aARIA%20was%20created%20to%20provide%20a%20bridge%20between%20the%20limitations%20of%20HTML%20and%20the%20need%20for%20making%20interactive%20experiences%20understandable%20by%20assistive%20technology.%0a&url=https://smashingmagazine.com%2f2025%2f06%2fwhat-i-wish-someone-told-me-aria%2f">
      
ARIA was created to provide a bridge between the limitations of HTML and the need for making interactive experiences understandable by assistive technology.

    </a>
  </p>
  <div class="pull-quote__quotation">
    <div class="pull-quote__bg">
      <span class="pull-quote__symbol">“</span></div>
  </div>
</blockquote>

<p>The latest version of ARIA is <a href="https://www.w3.org/TR/wai-aria-1.2/">version 1.2</a>, published on June 6th, 2023. Version 1.3 is slated to be released relatively soon, and you can read more about it in <a href="https://www.craigabbott.co.uk/blog/a-look-at-the-new-wai-aria-1-3-draft/">this excellent article by Craig Abbott</a>.</p>

<p>You may also see it referred to as WAI-ARIA, where WAI stands for “Web Accessibility Initiative.” The <a href="https://www.w3.org/WAI/">WAI</a> is part of the <a href="https://www.w3.org/">W3C</a>, the organization that sets standards for the web. That said, most accessibility practitioners I know call it “ARIA” in written and verbal communication and leave out the “WAI-” part.</p>

<h2 id="the-spirit-of-aria-reflects-the-era-in-which-it-was-created">The Spirit Of ARIA Reflects The Era In Which It Was Created</h2>

<p>The reason for this is simple: The web was a lot less mature in the past than it is now. The most popular operating system in 2006 was <a href="https://en.wikipedia.org/wiki/Windows_XP">Windows XP</a>. The iPhone didn’t exist yet; it was released a year later.</p>

<p>From a very high level, <strong>ARIA is a snapshot of the operating system interaction paradigms of this time period</strong>. This is because ARIA recreates them.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/3-wxpdefaultdesk.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="600"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/3-wxpdefaultdesk.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/3-wxpdefaultdesk.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/3-wxpdefaultdesk.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/3-wxpdefaultdesk.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/3-wxpdefaultdesk.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/3-wxpdefaultdesk.png"
			
			sizes="100vw"
			alt="Windows XP, showing an open Start menu, the famous Rolling Green Hills desktop wallpaper, and a tooltip popping up from the taskbar advising us to take a tour of Windows XP. Screenshot."
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Image source: <a href='https://the-microsoft-windows-xp.fandom.com/wiki/Windows_XP'>The Microsoft Windows XP Wiki</a>. (<a href='https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/3-wxpdefaultdesk.png'>Large preview</a>)
    </figcaption>
  
</figure>

<h3 id="the-mindset">The Mindset</h3>

<p>Smartphones with features like <a href="https://jquerymobile.com/">tappable</a>, swipeable, and draggable surfaces were far less commonplace. Single Page Application “web app” experiences were also rare, with <a href="https://en.wikipedia.org/wiki/Ajax_(programming)">Ajax</a>-based approaches being the most popular. This means that we have to <strong>build the experiences of today using the technology of 2006</strong>. In a way, <strong>this is a good thing</strong>. It forces us to take new and novel experiences and interrogate them.</p>

<p>Interactions that cannot be broken down into smaller, more focused pieces that map to ARIA patterns are most likely inaccessible. This is because they won’t be able to be operated by assistive technology or function on older or less popular devices.</p>

<p>I may be biased, but I also think these sorts of novel interactions that can’t translate also serve as a warning that a general audience will find them to be <strong>confusing and, therefore, unusable</strong>. This belief is important to consider given that the internet serves:</p>

<ul>
<li>An unknown number of people,</li>
<li>Using an unknown number of devices,</li>
<li>Each with an unknown amount of personal customizations,</li>
<li>Who have their own unique needs and circumstances and</li>
<li>Have unknown motivational factors.</li>
</ul>

<h3 id="interaction-expectations">Interaction Expectations</h3>

<p>Contemporary expectations for keyboard-based interaction for web content &mdash; checkboxes, radios, modals, accordions, and so on &mdash; are sourced from Windows XP and its predecessor operating systems. These interaction models are carried forward as muscle memory for older people who use assistive technology. Younger people who rely on assistive technology also learn these de facto standards, thus continuing the cycle.</p>

<p>What does this mean for you? Someone using a keyboard to interact with your website or web app <strong>will most likely</strong> <a href="https://github.blog/engineering/user-experience/considerations-for-making-a-tree-view-component-accessible/#start-with-windows"><strong>try these Windows OS-based keyboard shortcuts first</strong></a>. This means things like pressing:</p>

<ul>
<li><kbd>Enter</kbd> to navigate to a link’s destination,</li>
<li><kbd>Space</kbd> to activate buttons,</li>
<li><kbd>Home</kbd> and <kbd>End</kbd> to jump to the start or end of a list of items, and so on.</li>
</ul>

<h3 id="it-s-also-a-living-document">It’s Also A Living Document</h3>

<p>This is not to say that ARIA has stagnated. It is constantly being worked on with new additions, removals, and clarifications. Remember, it is now at version 1.2, with <a href="https://www.w3.org/TR/wai-aria-1.3/">version 1.3 arriving soon</a>.</p>

<p>In parallel, HTML as a language also reflects this evolution. Elements were originally created to support a document-oriented web and have been gradually evolving to <a href="https://open-ui.org/">support more dynamic, app-like experiences</a>. The great bit here is that this is all <a href="https://github.com/w3c/aria/">conducted in the open</a> and is something you can contribute to if you feel motivated to do so.</p>

<div class="partners__lead-place"></div>

<h2 id="aria-has-rules-for-using-it">ARIA Has Rules For Using It</h2>

<p>There are <a href="https://www.w3.org/TR/using-aria/#NOTES">five rules included in ARIA’s documentation</a> to help steer how you approach it:</p>

<ol>
<li><a href="https://www.w3.org/TR/using-aria/#firstrule">Use a native element whenever possible.</a><br />
An example would be using an anchor element (<code>&lt;a&gt;</code>) for a link rather than a <code>div</code> with a click handler and a <code>role</code> of <code>link</code>.</li>
<li><a href="https://www.w3.org/TR/using-aria/#secondrule">Don’t adjust a native element’s semantics if at all possible.</a><br />
An example would be trying to use a heading element as a tab rather than wrapping the heading in a semantically neutral <code>div</code>.</li>
<li><a href="https://www.w3.org/TR/using-aria/#3rdrule">Anything interactive has to be keyboard operable.</a><br />
If you can’t use it with a keyboard, it isn’t accessible. Full stop.</li>
<li><a href="https://www.w3.org/TR/using-aria/#4thrule">Do not use <code>role=&quot;presentation&quot;</code> or <code>aria-hidden=&quot;true&quot;</code> on a focusable element.</a><br />
This makes something intended to be interactive unable to be used by assistive technology.</li>
<li><a href="https://www.w3.org/TR/using-aria/#fifthrule">Interactive elements must be named.</a><br />
An example of this is using the text string “Print” for a <code>button</code> element.</li>
</ol>

<p>Observing these five rules will do a lot to help you out. The following is more context to provide even more support.</p>

<h2 id="aria-has-a-taxonomy">ARIA Has A Taxonomy</h2>

<p>There is a structured grammar to ARIA, and it is centered around roles, as well as states and properties.</p>

<h3 id="roles">Roles</h3>

<p>A <a href="https://www.w3.org/TR/wai-aria/#dfn-role">Role</a> is what assistive technology reads and then announces. A lot of people refer to this in shorthand as <em>semantics</em>. <strong>HTML elements have implied roles</strong>, which is why an anchor element will be announced as a link by screen readers with no additional work.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/4-roles.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="198"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/4-roles.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/4-roles.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/4-roles.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/4-roles.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/4-roles.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/4-roles.png"
			
			sizes="100vw"
			alt="Three panels, showing how an implied role gets announced by assistive technology. The first panel shows an anchor element with a string value of “French fries.” The anchor element has the label “Implied link role.” The second panel shows a standard blue link with an underline. The link reads, “French fries.” The third panel shows a speech balloon coming from a laptop. The speech balloon’s contents read, “French fries, link.” A label points to the speech balloon and reads, “Implied link role.”"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/4-roles.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p><strong>Implied roles are almost always better to use</strong> if the use case calls for them. Recall <a href="https://www.w3.org/TR/using-aria/#firstrule">the first rule of ARIA</a> here. This is usually what digital accessibility practitioners refer to when they say, “Just use semantic HTML.”</p>

<p>There are many reasons for favoring implied roles. The main consideration is better guarantees of support across an unknown number of operating systems, browsers, and assistive technology combinations.</p>

<p><a href="https://www.w3.org/TR/wai-aria/#roles_categorization">Roles have categories</a>, each with its own purpose. The <a href="https://www.w3.org/TR/wai-aria/#abstract_roles">Abstract role category</a> is notable in that it is an organizing <a href="https://en.wiktionary.org/wiki/supercategory">supercategory</a> <strong>not intended to be used by authors</strong>:</p>

<blockquote>Abstract roles are used for the ontology. Authors <strong>MUST NOT</strong> use abstract roles in content.</blockquote>

<pre><code class="language-html">&lt;!-- This won't work, don't do it --&gt;
&lt;h2 role="sectionhead"&gt;
  Anatomy and physiology
&lt;/h2&gt;

&lt;!-- Do this instead --&gt;
&lt;section aria-labeledby="anatomy-and-physiology"&gt;
  &lt;h2 id="anatomy-and-physiology"&gt;
    Anatomy and physiology
  &lt;/h2&gt;
&lt;/section&gt;
</code></pre>

<p>Additionally, in the same way, you can only declare ARIA on certain things, <strong>you can only declare some ARIA as children of other ARIA declarations</strong>. An example of this is the <a href="https://www.w3.org/TR/wai-aria/#listitem">the <code>listitem</code> role</a>, which requires <a href="https://www.w3.org/TR/wai-aria/#list">a role of <code>list</code></a> to be present on its parent element.</p>

<p>So, what’s the best way to determine if a role requires a parent declaration? The answer is to <a href="https://www.w3.org/TR/wai-aria/#role_definitions">review the official definition</a>.</p>

<h3 id="states-and-properties">States And Properties</h3>

<p><a href="https://www.w3.org/TR/wai-aria/#introstates">States and properties</a> are the other two main parts of ARIA‘s overall taxonomy.</p>

<p>Implicit roles are provided by semantic HTML, and explicit roles are provided by ARIA. Both describe <strong>what an element is</strong>. States <strong>describe that element’s characteristics in a way that assistive technology can understand</strong>. This is done via property declarations and their companion values.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/5-role-and-state.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="344"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/5-role-and-state.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/5-role-and-state.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/5-role-and-state.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/5-role-and-state.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/5-role-and-state.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/5-role-and-state.png"
			
			sizes="100vw"
			alt="A code example that shows how roles, states, and properties all work together. The first panel shows HTML code for a button element, which uses an ARIA declaration of aria disabled equals true. The button element is labeled as “Role”. The ARIA declaration, including both the property and value portions, is labeled “State.”"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/5-role-and-state.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>ARIA states can change quickly or slowly, both as a result of human interaction as well as application state. When the state is changed as a result of human interaction, it is considered an “unmanaged state.” Here, a developer must supply the underlying JavaScript logic to control the interaction.</p>

<p>When the state changes as a result of the application (e.g., operating system, web browser, and so on), this is considered “<a href="https://www.w3.org/TR/wai-aria/#dfn-managed-state">managed state</a>.” Here, the application automatically supplies the underlying logic.</p>

<h2 id="how-to-declare-aria">How To Declare ARIA</h2>

<p>Think of ARIA as an extension of <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes">HTML attributes</a>, a suite of name/value pairs. Some values are predefined, while others are author-supplied:</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/6-predefined-author-defined.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="432"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/6-predefined-author-defined.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/6-predefined-author-defined.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/6-predefined-author-defined.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/6-predefined-author-defined.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/6-predefined-author-defined.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/6-predefined-author-defined.png"
			
			sizes="100vw"
			alt="Two HTML declarations. One is a div element with an ARIA declaration of aria-live equals polite declared on it. The second is a button element with an ARIA declaration of aria-label equals save. The aria-live declaration is labeled “Predefined value,” and the aria-label declaration is labeled “Author-supplied value.”"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/6-predefined-author-defined.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>For the examples in the previous graphic, the <code>polite</code> value for <code>aria-live</code> is one of <a href="https://w3c.github.io/aria/#aria-live">the three predefined values</a> (<code>off</code>, <code>polite</code>, and <code>assertive</code>). For <code>aria-label</code>, “Save” is a text string manually supplied by the author.</p>

<p>You declare ARIA on HTML elements the same way you declare other attributes:</p>

<pre><code class="language-html">&lt;!-- 
  Applies an id value of 
  "carrot" to the div
--&gt;
&lt;div id="carrot"&gt;&lt;/div&gt;

&lt;!-- 
  Hides the content of this paragraph 
  element from assistive technology 
--&gt;
&lt;p aria-hidden="true"&gt;
  Assistive technology can't read this
&lt;/p&gt;

&lt;!-- 
  Provides an accessible name of "Stop", 
  and also communicates that the button 
  is currently pressed. A type property 
  with a value of "button" prevents 
  browser form submission.
--&gt;
&lt;button 
  aria-label="Stop"
  aria-pressed="true"
  type="button"&gt;
  &lt;!-- SVG icon --&gt;
&lt;/button&gt;
</code></pre>

<p>Other usage notes:</p>

<ul>
<li>You can place more than one ARIA declaration on an HTML element.</li>
<li>The order of placement of ARIA when declared on an HTML element does not matter.</li>
<li>There is no limit to how many ARIA declarations can be placed on an element. Be aware that <strong>the more you add, the more complexity you introduce</strong>, and more complexity means a larger chance <a href="https://www.a11yproject.com/posts/aria-has-perfect-support/">things may break or not function as expected</a>.</li>
<li>You can declare ARIA on an HTML element and also have other non-ARIA declarations, such as <code>class</code> or <code>id</code>. The order of declarations does not matter here, either.</li>
</ul>

<p>It might also be helpful to know that boolean attributes are treated a little differently in ARIA when compared to HTML. <a href="https://hidde.blog/">Hidde de Vries</a> writes about this in his post, <a href="https://hidde.blog/boolean-attributes-in-html-and-aria-whats-the-difference/">“Boolean attributes in HTML and ARIA: what&rsquo;s the difference?”</a>.</p>

<h2 id="not-a-whole-lot-of-aria-is-hardcoded">Not A Whole Lot Of ARIA Is “Hardcoded”</h2>

<p>In this context, “hardcoding” means directly writing a static attribute or value declaration into your component, view, or page.</p>

<p>A lot of ARIA is designed to be applied or conditionally modified dynamically based on <a href="https://www.freecodecamp.org/news/stateful-vs-stateless-architectures-explained/">application state</a> or as a response to someone’s action. An example of this is a show-and-hide disclosure pattern:</p>

<ul>
<li><a href="https://w3c.github.io/aria/#aria-expanded">ARIA’s <code>aria-expanded</code> attribute</a> is toggled from <code>false</code> to <code>true</code> to communicate if the disclosure is in an expanded or collapsed state.</li>
<li><a href="https://html.spec.whatwg.org/multipage/interaction.html#the-hidden-attribute">HTML’s <code>hidden</code> attribute</a> is conditionally removed or added in tandem to show or hide the disclosure’s full content area.</li>
</ul>

<div class="break-out">
<pre><code class="language-html">&lt;div class="disclosure-container"&gt;
  &lt;button 
    aria-expanded="false"
    class="disclosure-toggle"
    type="button"&gt;
    How we protect your personal information
  &lt;/button&gt;
  &lt;div 
    hidden
    class="disclosure-content"&gt;
    &lt;ul&gt;
      &lt;li&gt;Fast, accurate, thorough and non-stop protection from cyber attacks&lt;/li&gt;
      &lt;li&gt;Patching practices that address vulnerabilities that attackers try to exploit&lt;/li&gt;
      &lt;li&gt;Data loss prevention practices help to ensure data doesn't fall into the wrong hands&lt;/li&gt;
      &lt;li&gt;Supply risk management practices help ensure our suppliers adhere to our expectations&lt;/li&gt;
    &lt;/ul&gt;
    &lt;p&gt;
      &lt;a href="/security/"&gt;Learn more about our security best practices&lt;/a&gt;.
    &lt;/p&gt;
  &lt;/div&gt;
&lt;/div&gt;
</code></pre>
</div>

<p>A common example of a hardcoded ARIA declaration you’ll encounter on the web is <a href="https://www.smashingmagazine.com/2021/05/accessible-svg-patterns-comparison/">making an SVG icon inside a button decorative</a>:</p>

<pre><code class="language-html">&lt;button type="button&gt;
  &lt;svg aria-hidden="true"&gt;
    &lt;!-- SVG code --&gt;
  &lt;/svg&gt;
  Save
&lt;/button&gt;
</code></pre>

<p>Here, the string “Save” is what is required for someone to understand what the button will do when they activate it. The accompanying icon helps that understanding visually but is considered redundant and therefore <a href="https://www.w3.org/WAI/tutorials/images/decorative/">decorative</a>.</p>

<h2 id="declaring-an-aria-role-on-something-that-already-uses-that-role-implicitly-does-not-make-it-extra-accessible">Declaring An Aria Role On Something That Already Uses That Role Implicitly Does Not Make It “Extra” Accessible</h2>

<p>An implied role is all you need if you’re using semantic HTML. Explicitly declaring its role via ARIA does not confer any additional advantages.</p>

<pre><code class="language-html">&lt;!-- 
  You don't need to declare role="button" here.
  Using the &lt;button&gt; element will make assistive 
  technology announce it as a button. The 
  role="button" declaration is redundant.
 --&gt;
&lt;button role="button"&gt;
  Save
&lt;/button&gt;
</code></pre>

<p>You might occasionally run into these redundant declarations on <a href="https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/examples/HTML5.html">HTML sectioning elements</a>, such as <code>&lt;main role=&quot;main&quot;&gt;</code>, or <code>&lt;footer role=&quot;contentinfo&quot;&gt;</code>. This isn’t needed anymore, and you can just use the <code>&lt;main&gt;</code> or <code>&lt;footer&gt;</code> elements.</p>

<p>The reason for this is historic. These declarations were done for support reasons, in that it was a stop-gap technique for assistive technology that needed to be updated to support these <a href="https://www.w3.org/html/logo/">new-at-the-time HTML elements</a>.</p>

<p>Contemporary assistive technology does not need these redundant declarations. Think of it the same way that we don’t have to use vendor prefixes for the <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/border-radius">CSS <code>border-radius</code> property</a> anymore.</p>

<p><strong>Note</strong>: <em>There is an exception to this guidance. There are circumstances where certain complex and complicated markup patterns don’t work as expected for assistive technology. In these cases, we want to hardcode the implicit role as explicit ARIA to ensure it works. This assistive technology support concern is <a href="#the-more-aria-you-add-to-something-the-greater-the-chance-something-will-behave-unexpectedly">covered in more detail later in this post</a>.</em></p>

<h2 id="you-don-t-need-to-say-what-a-control-is-that-is-what-roles-are-for">You Don’t Need To Say What A Control Is; That Is What Roles Are For</h2>

<p>Both implicit and explicit roles are announced by screen readers. You don’t need to include that part for things like the interactive element’s text string or <a href="https://w3c.github.io/aria/#aria-label">an <code>aria-label</code></a>.</p>

<pre><code class="language-html">&lt;!-- Don't do this --&gt;
&lt;button 
  aria-label="Save button"
  type="button"&gt;
  &lt;!-- Icon SVG --&gt;
&lt;/button&gt;

&lt;!-- Do this instead --&gt;
&lt;button 
  aria-label="Save"
  type="button"&gt;
  &lt;!-- Icon SVG --&gt;
&lt;/button&gt;
</code></pre>

<p>Had we used the string value of “Save button” for our Save button, a screen reader would announce it along the lines of, “Save button, button.” That’s <a href="https://theideaplace.net/tooltip-should-not-start-an-accessible-name/">redundant</a> and confusing.</p>

<div class="partners__lead-place"></div>

<h2 id="aria-roles-have-very-specific-meanings">ARIA Roles Have Very Specific Meanings</h2>

<p>We sometimes refer to website and web app navigation colloquially as menus, especially if it’s an e-commerce-style <a href="https://www.nngroup.com/articles/mega-menus-work-well/">mega menu</a>.</p>

<p>In ARIA, <a href="https://w3c.github.io/aria/#menu">menus mean something very specific</a>. Don’t think of global or in-page navigation or the like. Think of menus in this context as what appears when you click the Edit menu button on your application’s menubar.</p>














<figure class="
  
  
  ">
  
    <a href="https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/7-menu.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="712"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/7-menu.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/7-menu.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/7-menu.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/7-menu.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/7-menu.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/7-menu.png"
			
			sizes="100vw"
			alt="The edit menu option activated on Windows Notepad. It shows a list of menu options, with the option for “Go to” being in focus. Some options are disabled, as there is no content in the Notepad file, nor is there anything on the Windows Clipboard. The other menu options are Undo, Cut, Copy, Paste, Delete, Search with Bing, Find, Find Next, Find Previous, Replace, Select All, Time/Date, and Font. Screenshot."
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Notepad, Windows 11. (<a href='https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/7-menu.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>Using a role improperly because its name seems like an appropriate fit at first glance creates confusion for people who do not have the context of the visual UI. <strong>Their expectations will be set with the announcement of the role</strong>, then subverted when it does not act the way it is supposed to.</p>

<p>Imagine if you click on a link, and instead of taking you to another webpage, it sends something completely unrelated to your printer instead. It’s sort of like that.</p>

<p>Declaring <code>role=&quot;menu&quot;</code> is a common example of a misapplied role, but there are others. The best way to know what a role is used for? <a href="https://www.w3.org/TR/wai-aria/#role_definitions">Go straight to the source</a> and read up on it.</p>

<h2 id="certain-roles-are-forbidden-from-having-accessible-names">Certain Roles Are Forbidden From Having Accessible Names</h2>

<p>These roles are <code>caption</code>, <code>code</code>, <code>deletion</code>, <code>emphasis</code>, <code>generic</code>, <code>insertion</code>, <code>paragraph</code>, <code>presentation</code>, <code>strong</code>, <code>subscript</code>, and <code>superscript</code>.</p>

<p>This means you can try and provide an accessible name for one of these elements &mdash; say via <code>aria-label</code> &mdash; but it won’t work because it’s disallowed by <a href="https://www.w3.org/TR/wai-aria-1.2/#namefromprohibited">the rules of ARIA’s grammar</a>.</p>

<div class="break-out">
<pre><code class="language-html">&lt;!-- This won't work--&gt;
&lt;strong aria-label="A 35% discount!"&gt;
  $39.95
&lt;/strong&gt;

&lt;!-- Neither will this --&gt;
&lt;code title="let JavaScript example"&gt;
  let submitButton = document.querySelector('button[type="submit"]');
&lt;/code&gt;
</code></pre>
</div>

<p>For these examples, recall that the role is implicit, sourced from the declared HTML element.</p>

<p>Note here that sometimes a browser will make an attempt regardless and overwrite the author-specified string value. This overriding is a confusing act for all involved, which led to the rule being established in the first place.</p>

<h2 id="you-can-t-make-up-aria-and-expect-it-to-work">You Can’t Make Up ARIA And Expect It To Work</h2>

<p>I’ve witnessed some developers guess-adding CSS classes, such as <code>.background-red</code> or <code>.text-white</code>, to their markup and being rewarded if the design visually updates correctly.</p>

<p>The reason this works is that someone previously added those classes to the project. With ARIA, the people who add the content we can use are the <a href="https://www.w3.org/WAI/about/groups/ariawg/">Accessible Rich Internet Applications Working Group</a>. This means each new version of ARIA has a predefined set of properties and values. Assistive technology is then updated to parse those attributes and values, <a href="https://ericwbailey.website/published/it-needs-to-map-back-to-a-role/#edicts-still-need-to-be-carried-out">although this isn’t always a guarantee</a>.</p>

<p>Declaring ARIA, which isn’t part of that predefined set, means assistive technology won’t know what it is and consequently won’t announce it.</p>

<pre><code class="language-html">&lt;!-- 
  There is no "selectpanel" role in ARIA.
  Because of this, this code will be announced 
  as a button and not as a select panel.
--&gt;
&lt;button 
  role="selectpanel"
  type="button"&gt;
  Choose resources
&lt;/button&gt;
</code></pre>

<h2 id="aria-fails-silently">ARIA Fails Silently</h2>

<p>This speaks to the previous section, where ARIA won’t understand words spoken to it that exist outside its limited vocabulary.</p>

<p><strong>There are no console errors for malformed ARIA</strong>. There’s also no alert dialog, beeping sound, or flashing light for your operating system, browser, or assistive technology. This fact is yet another reason why it is so important to <a href="https://webaim.org/articles/nvda/"><strong>test with actual assistive technology</strong></a>.</p>

<p><a href="https://webaim.org/articles/screenreader_testing/">You don’t have to be an expert</a> here, either. There is a good chance your code needs updating if you set something to announce as a specific <a href="https://www.w3.org/TR/wai-aria/#introstates">state</a> and assistive technology in its default configuration does not announce that state.</p>

<h2 id="aria-only-exposes-the-presence-of-something-to-assistive-technology">ARIA Only Exposes The Presence Of Something To Assistive Technology</h2>

<p><strong>Applying ARIA to something does not automatically “unlock” capabilities</strong>. It <strong>only</strong> sends a hint to assistive technology about how the interactive content should behave.</p>

<p>For assistive technology like screen readers, that hint could be for how to announce something. For assistive technology like <a href="https://www.afb.org/node/16207/refreshable-braille-displays">refreshable Braille displays</a>, it could be for how it raises and lowers its pins. For example, <strong>declaring <code>role=&quot;button&quot;</code> on a <code>div</code> element does not automatically make it clickable</strong>. You will still need to:</p>

<ul>
<li>Target the <code>div</code> element in JavaScript,</li>
<li>Tie it to a <a href="https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event">click event</a>,</li>
<li>Author the interactive logic that it performs when clicked, and then</li>
<li>Accommodate <a href="https://adrianroselli.com/2022/04/brief-note-on-buttons-enter-and-space.html">all the other expected behaviors</a>.</li>
</ul>

<p>This all makes me wonder why you can’t save yourself some work and use a <code>button</code> element in the first place, but that is a different story for a different day.</p>

<p>Additionally, <strong>adjusting an element’s role via ARIA does not modify the element’s native functionality</strong>. For example, you can declare <code>role=&quot;image&quot;</code> on a <code>div</code> element. However, attempting to declare the <code>alt</code> or <code>src</code> attributes on the <code>div</code> won’t work. This is because <code>alt</code> and <code>src</code> are <a href="https://html.spec.whatwg.org/multipage/grouping-content.html#the-div-element">not supported attributes for <code>div</code></a>.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/8-image%20element-div.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="289"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/8-image%20element-div.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/8-image%20element-div.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/8-image%20element-div.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/8-image%20element-div.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/8-image%20element-div.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/8-image%20element-div.png"
			
			sizes="100vw"
			alt="Two panels, one labeled “Will work” and the other labeled, “Won’t work.” The panel labeled “Will work” shows an image element with an alt and src attribute. The panel labeled “Won’t work” shows a div with a role of image, as well as alt and src attributes. Both src attributes link to a file called cucumber.jpg, and both alt attributes use a string value of “A small cucumber.”"
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/8-image%20element-div.png'>Large preview</a>)
    </figcaption>
  
</figure>

<h2 id="declaring-an-aria-role-on-something-will-override-its-semantics-but-not-its-behavior">Declaring an ARIA Role On Something Will Override Its Semantics, But Not Its Behavior</h2>

<p>This speaks to the previous section on <strong>ARIA only exposing something’s presence</strong>. Don’t forget that certain HTML elements have primary and secondary interactive capabilities built into them.</p>

<p>For example, an anchor element’s primary capability is navigating to whatever URL value is provided for its <code>href</code> attribute. Secondary capabilities for an anchor element include copying the URL value, opening it in a new tab or incognito window, and so on.</p>














<figure class="
  
  
  ">
  
    <a href="https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/9-right-click.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="720"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/9-right-click.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/9-right-click.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/9-right-click.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/9-right-click.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/9-right-click.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/9-right-click.png"
			
			sizes="100vw"
			alt="A link whose string value is “Link with a role set to button.” Above it is text that reads, “For demonstration purposes only. Please don’t do this.” The link has a cursor placed over it, with an active right-click menu. The menu shows multiple actions you can take on the link, including opening it in a new tab or window, copying and saving the link address, searching the web for the link’s string value, as well as options provided by user-installed browser extensions. These options are managing the link with the 1Password password manager and copying a link to the selected text. Cropped screenshot."
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Chrome on macOS. Note the support for user-installed browser extensions. (<a href='https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/9-right-click.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>These secondary capabilities are still preserved. However, it may not be apparent to someone that they can use them &mdash; or use them in the way that they’d expect &mdash; depending on what is announced.</p>

<p>The opposite is also true. When an element has no capabilities, having its role adjusted does not grant it any new abilities. Remember, <a href="#aria-only-exposes-the-presence-of-something-to-assistive-technology"><strong>ARIA only announces</strong></a>. This is why that <code>div</code> with a <code>role</code> of <code>button</code> assigned to it won’t do anything when clicked if no companion JavaScript logic is also present.</p>














<figure class="
  
  
  ">
  
    <a href="https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/keyboard-support.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="705"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/keyboard-support.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/keyboard-support.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/keyboard-support.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/keyboard-support.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/keyboard-support.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/keyboard-support.png"
			
			sizes="100vw"
			alt="Two side-by-side graphics, each one consisting of three panels. The first panel on the left of the graphic shows the HTML code for a button element. The first panel for the right graphic shows HTML code for a div with a role of button. Both examples use a string value of “Favorite” and have a class of “button-fav” applied to them. The second panel for both left and right graphics shows an identical-looking button labeled “Favorite”, which has a golden-colored background. The third panel for the left graphic shows support for Enter and Space keypresses. The third panel for the right graphic shows no support for Enter and Space keypresses."
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/keyboard-support.png'>Large preview</a>)
    </figcaption>
  
</figure>

<h2 id="you-will-need-to-declare-aria-to-make-certain-interactions-accessible">You Will Need To Declare ARIA To Make Certain Interactions Accessible</h2>

<p>A lot of the previous content may make it seem like ARIA is something you should avoid using altogether. This isn’t true. Know that this guidance is written to help steer you to <strong>situations where HTML does not offer the capability to describe an interaction</strong> out of the box. <strong>This space is where you want to use ARIA</strong>.</p>

<p>Knowing how to identify this area requires spending some time learning what HTML elements there are, as well as what they are and are not used for. I quite like <a href="https://html5doctor.com/">HTML5 Doctor’s Element Index</a> for upskilling on this.</p>

<h2 id="certain-aria-states-require-certain-aria-roles-to-be-present">Certain ARIA States Require Certain ARIA Roles To Be Present</h2>

<p>This is analogous to how HTML has both <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes">global attributes</a> and attributes that can only be used on a per-element basis. For example, <a href="https://w3c.github.io/aria/#aria-describedby"><code>aria-describedby</code> can be used on any HTML element</a> or role. However, <a href="https://w3c.github.io/aria/#aria-posinset"><code>aria-posinset</code> can only be used with <code>article</code>, <code>comment</code>, <code>listitem</code>, <code>menuitem</code>, <code>option</code>, <code>radio</code>, <code>row</code>, and <code>tab</code> roles</a>. Remember here that these roles can be provided by either HTML or ARIA.</p>

<p>Learning what states require which roles can be achieved by <a href="https://www.w3.org/TR/wai-aria/#state_prop_def">reading the official reference</a>. Check for the “Used in Roles” portion of each entry’s characteristics:</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/11-used-in-roles.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="523"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/11-used-in-roles.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/11-used-in-roles.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/11-used-in-roles.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/11-used-in-roles.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/11-used-in-roles.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/11-used-in-roles.png"
			
			sizes="100vw"
			alt=" A characteristics table for aria setsize. The table’s two columns are labeled “Characteristic” and “Value.” The second table row is highlighted, demonstrating where you look for what role supports what state. The First row’s first cell has the text, “Used in roles.” The first row’s second cell has the text, “article, listitem, menuitem, option, radio, row, tab.” The second row’s first cell has the text, “Inherits into Roles.” The second row’s second cell has the text, “menuitemcheckbox, menuitemradio, treeitem.” The third row’s first cell has the text “Value.” Cropped screenshot."
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Characteristics for <code>aria-setsize</code>. (<a href='https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/11-used-in-roles.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>Automated code scanners &mdash; like <a href="https://www.deque.com/axe/">axe</a>, <a href="https://wave.webaim.org/">WAVE</a>, <a href="https://www.tpgi.com/arc-platform/arc-toolkit/">ARC Toolkit</a>, <a href="https://pa11y.org/">Pa11y</a>, <a href="https://github.com/IBMa/equal-access#equal-access">equal-access</a>, and so on &mdash; can catch this sort of thing if they are written in error. I’m a big fan of implementing these sorts of checks as part of a <a href="https://en.wikipedia.org/wiki/Continuous_integration">continuous integration</a> strategy, as it makes it a code quality concern shared across the whole team.</p>

<h2 id="aria-is-more-than-web-browsers">ARIA Is More Than Web Browsers</h2>

<p>Speaking of technology that listens, it is helpful to know that the ARIA you declare <strong>instructs the browser to speak to the operating system</strong> the browser is installed on. Assistive technology then listens to <a href="https://www.w3.org/TR/wai-aria/#dfn-accessibility-tree">what the operating system reports</a>. It then communicates that to the person using the computer, tablet, smartphone, and so on.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/12-flowchart-four-steps.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="296"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/12-flowchart-four-steps.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/12-flowchart-four-steps.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/12-flowchart-four-steps.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/12-flowchart-four-steps.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/12-flowchart-four-steps.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/12-flowchart-four-steps.png"
			
			sizes="100vw"
			alt="A flowchart with four steps. The first step is a webpage with a code icon floating above it. The second step is a computer, with an icon of an indented list floating above it. The third step is the symbol for accessibility, a Vitruvian man in a circle. Above this icon is a speech bubble. The fourth and final step is a person, with an icon of a lit lightbulb floating above it."
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/12-flowchart-four-steps.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>A person can then instruct assistive technology to request the operating system to take action on the web content displayed in the browser.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/13-flowchart-four-steps.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="296"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/13-flowchart-four-steps.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/13-flowchart-four-steps.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/13-flowchart-four-steps.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/13-flowchart-four-steps.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/13-flowchart-four-steps.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/13-flowchart-four-steps.png"
			
			sizes="100vw"
			alt="A flowchart with four steps. The first step is a person with an icon of a finger pressing a button floating above it. The second step is the symbol for accessibility, a Vitruvian man in a circle. Above this icon is a speech bubble. The third step is a computer, with an icon of a handshake floating above it. The fourth and final step is an updated webpage, with a clicking mouse cursor icon floating above it."
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/13-flowchart-four-steps.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p><strong>This interaction model is by design</strong>. It is done to make interaction from assistive technology indistinguishable from interaction performed without assistive technology.</p>

<p>There are a few reasons for this approach. The most important one is <a href="https://css-tricks.com/accessibility-events/">it helps <strong>preserve the privacy and autonomy</strong></a> of the <a href="https://accessaces.com/what-disabled-people-have-to-give-up-in-the-name-of-accessibility/">people who rely on assistive technologies</a>.</p>

<h2 id="just-because-it-exists-in-the-aria-spec-does-not-mean-assistive-technology-will-support-it">Just Because It Exists In The ARIA Spec Does Not Mean Assistive Technology Will Support It</h2>

<p>This support issue was touched on earlier and is a difficult fact to come to terms with.</p>

<p>Contemporary developers enjoy the hard-fought, hard-won benefits of <a href="https://www.webstandards.org/">the web standards movement</a>. This means you can declare HTML and know that it will <a href="https://www.w3.org/standards/">work with every major browser</a> out there. ARIA does not have this. <strong>Each assistive technology vendor has its own interpretation of the ARIA specification</strong>. Oftentimes, these interpretations are convergent. Sometimes, they’re not.</p>

<p>Assistive technology vendors also have support roadmaps for their products. Some assistive technology vendors:</p>

<ul>
<li>Will eventually add support,</li>
<li>May never, and some</li>
<li>Might do so in a way that contradicts how other vendors choose to implement things.</li>
</ul>

<p>There is also the operating system layer to contend with, which I’ll cover in more detail in a little bit. Here, the mechanisms used to communicate with assistive technology are dusty, oft-neglected areas of software development.</p>

<p>With these layers comes a scenario where <strong>the assistive technology can support the ARIA declared, but the operating system itself cannot communicate the ARIA’s presence, or vice-versa</strong>. The reasons for this are varied but ultimately boil down to a historic lack of support, prioritization, and resources. However, I am <a href="https://aria-at.w3.org/">optimistic that this is changing</a>.</p>

<p>Additionally, <strong>there is no equivalent to <a href="https://caniuse.com/">Caniuse</a>, <a href="https://web.dev/baseline">Baseline</a>, or <a href="https://webstatus.dev/">Web Platform Status</a> for assistive technology</strong>. The closest analog we have to support checking resources is <a href="https://a11ysupport.io/">a11ysupport.io</a>, but know that it is the painstaking work of a single individual. Its content may not be up-to-date, as the work is both Herculean in its scale and Sisyphean in its scope. Because of this, I must re-stress <a href="https://www.smashingmagazine.com/2018/09/importance-manual-accessibility-testing/"><strong>the importance of manually testing with assistive technology</strong></a> to determine if the ARIA you use works as intended.</p>

<p><strong>How To Determine ARIA Support</strong></p>

<p>There are three main layers to determine if something is supported:</p>

<ol>
<li>Operating system and version.</li>
<li>Assistive technology and version,</li>
<li>Browser and browser version.</li>
</ol>

<h3 id="1-operating-system-and-version">1. Operating System And Version</h3>

<p>Each operating system (e.g., Windows, macOS, Linux) has its own way of <a href="https://alistapart.com/article/semantics-to-screen-readers/">communicating what content is present to assistive technology</a>. Each piece of assistive technology has to accommodate <strong>how</strong> to parse that communication.</p>

<p>Some assistive technology is incompatible with certain operating systems. An example of this is not being able to use <a href="https://support.apple.com/guide/voiceover/get-started-vo4be8816d70/10/mac/15.0">VoiceOver</a> with Windows, or <a href="https://www.freedomscientific.com/products/software/jaws/">JAWS</a> with macOS. Furthermore, each version of each operating system has slight variations in what is reported and how. Sometimes, the operating system needs to be updated to “teach” it the updated AIRA vocabulary. Also, do not forget that things like <a href="https://github.com/FreedomScientific/standards-support/issues">bugs and regressions</a> can occur.</p>

<h3 id="2-assistive-technology-and-version">2. Assistive Technology And Version</h3>

<p><strong>There is no “one true way” to make assistive technology</strong>. Each one is built to address different access needs and wants and is done so in an opinionated way &mdash; think how different web browsers have different features and UI.</p>

<p>Each piece of assistive technology that consumes web content has its own way of communicating this information, and <strong>this is by design</strong>. It works with what the operating system reports, filtered through things like heuristics and preferences.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/14-voice-control.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="586"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/14-voice-control.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/14-voice-control.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/14-voice-control.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/14-voice-control.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/14-voice-control.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/14-voice-control.png"
			
			sizes="100vw"
			alt="A three by three grid of nine buttons, with a title of “Select your order.” Each button has a food-related emoji, with a tooltip showing the button’s accessible name. The buttons are a hamburger with the title “100% Angus Beef Burger”, french fries with the title “Special Smile Fries”, a pizza slice with the title “Pepperoni Pizza”, a hot dog with the title “Hot Dog With Mustard”, a sandwich with a title of “Ham Sando”, a taco with the title of “Tuesday Taco”, a plate of spaghetti with the title of “Pasgetti”, a waffle with the title of “Waffles Sans Chicken”, and some popcorn with the title of “Poppin’ Corn”."
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      The “Show names” command in <a href='https://support.apple.com/en-us/102225'>macOS Voice Control</a>, which displays the accessible names of these icon buttons. The accessible name has been supplied by <code>aria-label</code>. (<a href='https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/14-voice-control.png'>Large preview</a>)
    </figcaption>
  
</figure>

<p>Like operating systems, assistive technology also has different versions with what each version is capable of supporting. They can also be susceptible to bugs and regressions.</p>

<p>Another two factors worth pointing out here are <strong>upgrade hesitancy</strong> and <strong>lack of financial resources</strong>. Some people who rely on assistive technology are hesitant to upgrade it. This is based on a very understandable fear of breaking an important mechanism they use to interact with the world. This, in turn, translates to scenarios like holding off on updates until absolutely necessary, as well as disabling auto-updating functionality altogether.</p>

<p>Lack of financial resources is sometimes referred to as <a href="https://stimpunks.org/glossary/crip-tax/">the disability or crip tax</a>. <a href="https://www.un.org/development/desa/disabilities/resources/factsheet-on-persons-with-disabilities/disability-and-employment.html">Employment rates tend to be lower for disabled populations</a>, and with that comes less money to spend on acquiring new technology and updating it. This concern can and does apply to operating systems, browsers, and assistive technology.</p>

<h3 id="3-browser-and-browser-version">3. Browser And Browser Version</h3>

<p>Some assistive technology works better with one browser compared to another. This is due to the underlying mechanics of <strong>how the browser reports its content to assistive technology</strong>. Using Firefox with NVDA is an example of this.</p>

<p>Additionally, the support for this reporting sometimes only gets added for newer versions. Unfortunately, it also means support can sometimes accidentally regress, and people don’t notice before releasing the browser update &mdash; again, this is due to a historic lack of resources and prioritization.</p>

<h2 id="the-less-commonly-used-the-aria-you-declare-the-greater-the-chance-you-ll-need-to-test-it">The Less Commonly-Used The ARIA You Declare, The Greater The Chance You’ll Need To Test It</h2>

<p>Common ARIA declarations you’ll come across include, but are not limited to:</p>

<ul>
<li><code>aria-label</code>,</li>
<li><code>aria-labelledby</code>,</li>
<li><code>aria-describedby</code>,</li>
<li><code>aria-hidden</code>,</li>
<li><code>aria-live</code>.</li>
</ul>

<p>These are more common because they’re more supported. They are more supported because many of these declarations have been around for a while. Recall <a href="#just-because-it-exists-in-the-aria-spec-does-not-mean-assistive-technology-will-support-it">the previous section that discussed actual assistive technology support</a> compared to what the ARIA specification supplies.</p>

<p>Newer, more esoteric ARIA, or historically deprioritized declarations, may not have that support yet or may never. An example of how complicated this can get is <a href="https://w3c.github.io/aria/#aria-controls"><code>aria-controls</code></a>.</p>

<p><code>aria-controls</code> is a part of ARIA that has been around for a while. <a href="https://www.freedomscientific.com/products/software/jaws/">JAWS</a> had support for <code>aria-controls</code>, but then removed it after user feedback. Meanwhile, every other screen reader I’m aware of never bothered to add support.</p>

<p>What does that mean for us? Determining support, or lack thereof, is best accomplished by <strong>manual testing with assistive technology.</strong></p>

<h2 id="the-more-aria-you-add-to-something-the-greater-the-chance-something-will-behave-unexpectedly">The More ARIA You Add To Something, The Greater The Chance Something Will Behave Unexpectedly</h2>

<p>This fact takes into consideration the complexities in preferences, different levels of support, bugs, regressions, and other concerns that come with ARIA’s usage.</p>

<p>Philosophically, it’s a lot like adding more interactive complexity to your website or web app via JavaScript. The larger the surface area your code covers, <strong>the bigger the chance something unintended happens</strong>.</p>

<p>Consider the amount of ARIA added to a component or discrete part of your experience. The more of it there is declared nested into <a href="https://dom.spec.whatwg.org/">the Document Object Model (DOM)</a>, the more it interacts with parent ARIA declarations. This is because assistive technology reads what the DOM exposes to help determine intent.</p>

<p>A lot of contemporary development efforts are isolated, feature-based work that focuses on one small portion of the overall experience. Because of this, they may not take this holistic nesting situation into account. This is another reason why &mdash; you guessed it &mdash; manual testing is so important.</p>

<p>Anecdotally, <a href="https://webaim.org/projects/million/#aria">WebAIM’s annual Millions report</a> &mdash; an accessibility evaluation of the top 1,000,000 websites &mdash; touches on this phenomenon:</p>

<blockquote><strong>Increased ARIA usage on pages was associated with higher detected errors. The more ARIA attributes that were present, the more detected accessibility errors could be expected.</strong> This does not necessarily mean that ARIA introduced these errors (these pages are more complex), but pages typically had significantly more errors when ARIA was present.</blockquote>

<h2 id="assistive-technology-may-support-your-invalid-aria-declaration">Assistive Technology May Support Your Invalid ARIA Declaration</h2>

<p>There is a chance that ARIA, which is authored inaccurately, will actually function as intended with assistive technology. While <strong>I do not recommend betting on this fact to do your work</strong>, I do think it is worth mentioning when it comes to things like debugging.</p>

<p>This is due to the wide range of familiarity there is with people who author ARIA.</p>

<p>Some of the more mature assistive technology vendors try to accommodate the lower end of this familiarity. This is done in order to <strong>better enable the people who use their software to actually get what they need</strong>.</p>

<p>There isn’t an exhaustive list of what accommodations each piece of assistive technology has. Think of it like <a href="https://quandyfactory.com/blog/39/the_virtue_of_forgiving_html_parsers">the forgiving nature of a browser’s HTML parser</a>, where <strong>the ultimate goal is to render content for humans</strong>.</p>

<h2 id="aria-label-is-tricky"><code>aria-label</code> Is Tricky</h2>

<p><a href="https://w3c.github.io/aria/#aria-label"><code>aria-label</code></a> is one of the most common ARIA declarations you’ll run across. It’s also one of the most misused.</p>

<p><a href="https://benmyers.dev/blog/dont-use-aria-label-on-static-text-elements/"><code>aria-label</code> can’t be applied to non-interactive HTML elements</a>, but oftentimes is. It <a href="https://adrianroselli.com/2019/11/aria-label-does-not-translate.html">can’t always be translated</a> and is oftentimes <a href="https://ericwbailey.website/published/what-they-dont-tell-you-when-you-translate-your-app/#you%E2%80%99ll-need-to-translate-%2F-localize-more-than-you-think-you-will">overlooked for localization efforts</a>. Additionally, it can make things frustrating to operate for people who use voice control software, where the visible label differs from what the underlying code uses.</p>

<p>Another problem is when it overrides an interactive element’s pre-existing accessible name. For example:</p>

<pre><code class="language-html">&lt;!-- Don't do this --&gt;
&lt;a 
  aria-label="Our services"
  href="/services/"&gt;
  Services
&lt;/a&gt;
</code></pre>

<p>This is a violation of <a href="https://www.w3.org/WAI/WCAG21/Understanding/label-in-name.html">WCAG Success Criterion 2.5.3: Label in Name</a>, pure and simple. I have also seen it used as a way to provide a <a href="https://adrianroselli.com/2019/10/stop-giving-control-hints-to-screen-readers.html">control hint</a>. This is also a WCAG failure, in addition to being an antipattern:</p>

<div class="break-out">
<pre><code class="language-html">&lt;!-- Also don't do this --&gt;
&lt;a 
  aria-label="Click this link to learn more about our unique and valuable services"
  href="/services/"&gt;
  Services
&lt;/a&gt;
</code></pre>
</div>

<p>These factors &mdash; along with other considerations &mdash; are why I consider <a href="https://ericwbailey.website/published/aria-label-is-a-code-smell/"><code>aria-label</code> a code smell</a>.</p>

<h2 id="aria-live-is-even-trickier"><code>aria-live</code> Is Even Trickier</h2>

<p>Live region announcements are <a href="https://w3c.github.io/aria/#aria-live">powered by <code>aria-live</code></a> and are an important part of communicating updates to an experience to people who use screen readers.</p>

<p>Believe me when I say that getting <code>aria-live</code> to work properly is tricky, even under the best of scenarios. I won’t belabor the specifics here. Instead, I’ll point you to <a href="https://tetralogical.com/blog/2024/05/01/why-are-my-live-regions-not-working/">“Why are my live regions not working?”</a>, a fantastic and comprehensive article published by TetraLogical.</p>

<h2 id="the-aria-authoring-practices-guide-can-lead-you-astray">The ARIA Authoring Practices Guide Can Lead You Astray</h2>

<p>Also referred to as the APG, the <a href="https://www.w3.org/WAI/ARIA/apg/">ARIA Authoring Practices Guide</a> should be treated with a decent amount of caution.</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/15-apg-caution.png">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="463"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/15-apg-caution.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/15-apg-caution.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/15-apg-caution.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/15-apg-caution.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/15-apg-caution.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/15-apg-caution.png"
			
			sizes="100vw"
			alt="A screenshot of the ARIA Authoring Practices Guide homepage, with a yellow caution tape placed across it."
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      (<a href='https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/15-apg-caution.png'>Large preview</a>)
    </figcaption>
  
</figure>

<h3 id="the-downsides">The Downsides</h3>

<p>The guide was originally authored to help demonstrate ARIA’s capabilities. As a result, <strong>its code examples near-exclusively, overwhelmingly, and disproportionately favor ARIA</strong>.</p>

<p>Unfortunately, the APG’s latest redesign also makes it far more approachable-looking than its surrounding W3C documentation. This is coupled with <a href="https://www.w3.org/WAI/ARIA/apg/patterns/">demonstrating UI patterns</a> in a way that signals it’s a self-serve resource whose code can be used out of the box.</p>

<p>These factors create a scenario where people assume everything can be used as presented. This is <strong>not true</strong>.</p>

<p>Recall that just because ARIA is listed in the spec <a href="#just-because-it-exists-in-the-aria-spec-does-not-mean-assistive-technology-will-support-it">does not necessarily guarantee it is supported</a>. Adrian Roselli writes about this in detail in his post, <a href="https://adrianroselli.com/2023/04/no-apgs-support-charts-are-not-can-i-use-for-aria.html">“No, APG’s Support Charts Are Not ‘Can I Use’ for ARIA”</a>.</p>

<p>Also, remember <a href="https://www.w3.org/TR/using-aria/#firstrule">the first rule of ARIA</a> and know that <a href="#aria-has-rules-for-using-it">an ARIA-first approach is counter to the specification’s core philosophy of use</a>.</p>

<p>In my experience, this has led to developers assuming they can copy-paste code examples or reference how it’s structured in their own efforts, and everything will just work. This leads to mass frustration:</p>

<ul>
<li>Digital accessibility practitioners have to explain that “doing the right thing” isn’t going to work as intended.</li>
<li>Developers then have to revisit their work to update it.</li>
<li>Most importantly, people who rely on assistive technology risk not being able to use something.</li>
</ul>

<p>This is to say nothing about things like timelines and resourcing, working relationships, reputation, and brand perception.</p>

<h3 id="the-upside">The Upside</h3>

<p>The APG’s main strength is <strong>highlighting what keyboard keypresses people will expect to work</strong> on each pattern.</p>

<p>Consider <a href="https://www.w3.org/WAI/ARIA/apg/patterns/listbox/#keyboardinteraction">the listbox pattern</a>. It details keypresses you may expect (arrow keys, <kbd>Space</kbd>, and <kbd>Enter</kbd>), as well as less-common ones (<a href="https://en.wikipedia.org/wiki/Typeahead">typeahead</a> selection and making multiple selections). Here, we need to <a href="#the-spirit-of-aria-reflects-the-era-in-which-it-was-created">remember that ARIA is based on the Windows XP era</a>. The keyboard-based interaction the APG suggests is built from the muscle memory established from the UI patterns used on this operating system.</p>

<p>While your tree view component may look visually different from the one on your operating system, <a href="https://github.blog/engineering/user-experience/considerations-for-making-a-tree-view-component-accessible/#start-with-windows">people will expect it to be keyboard operable in the same way</a>. Honoring this expectation will go a long way to <strong>ensuring your experiences are not only accessible but also intuitive and efficient to use</strong>.</p>

<p>Another strength of the APG is giving <a href="https://www.w3.org/WAI/ARIA/apg/patterns/">standardized, centralized names to UI patterns</a>. Is it a dropdown? A listbox? A combobox? A select menu? <a href="https://adrianroselli.com/2020/03/stop-using-drop-down.html">Something else</a>?</p>

<p>When it comes to digital accessibility, these terms all have specific meanings, as well as expectations that come with them. Having a common vocabulary when discussing how an experience should work goes a long way to <strong>ensuring everyone will be on the same page</strong> when it comes time to make and maintain things.</p>

<h2 id="macos-voiceover-can-also-lead-you-astray">macOS VoiceOver Can Also Lead You Astray</h2>

<p><a href="https://support.apple.com/guide/voiceover/welcome/mac">VoiceOver on macOS</a> has been <a href="https://www.applevis.com/forum/macos-mac-apps/state-screen-readers-macos">experiencing a lot of problems</a> over the last few years. If I could wager a guess as to why this is, as an outsider, it is that Apple’s priorities are <a href="https://www.apple.com/visionos/visionos-2/">focused elsewhere</a>.</p>

<p>The bulk of web development efforts are conducted on macOS. This means that well-intentioned developers will reach for VoiceOver, as it comes bundled with macOS and is therefore more convenient. However, macOS VoiceOver usage has a drastic minority share for desktops and laptops. It is under 10% of usage, with Windows-based JAWS and NVDA occupying a combined 78.2% majority share:</p>














<figure class="
  
    break-out article__image
  
  
  ">
  
    <a href="https://webaim.org/projects/screenreadersurvey10/#primary">
    
    <img
      loading="lazy"
      decoding="async"
      fetchpriority="low"
			width="800"
			height="526"
			
			srcset="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/16-webaim-pie-chart.png 400w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_800/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/16-webaim-pie-chart.png 800w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1200/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/16-webaim-pie-chart.png 1200w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_1600/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/16-webaim-pie-chart.png 1600w,
			        https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/16-webaim-pie-chart.png 2000w"
			src="https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_400/https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/16-webaim-pie-chart.png"
			
			sizes="100vw"
			alt="A pie chart. The legend of the pie chart reads, “JAWS, 40.5%”, “NVDA, 37.7%”, “VoiceOver, 9.7%”, “SuperNova, 3.7%”, “ZoomText, 207%”, “Orca, 2.4%”, “Narrator, 0.7%”, and “Other, 2.7%.” Cropped screenshot."
		/>
    
    </a>
  

  
    <figcaption class="op-vertical-bottom">
      Image source: <a href='https://webaim.org/projects/screenreadersurvey10/#primary'>WebAIM Screen Reader User Survey #10</a>. (<a href='https://files.smashing.media/articles/what-i-wish-someone-told-me-aria/16-webaim-pie-chart.png'>Large preview</a>)
    </figcaption>
  
</figure>

<h3 id="the-problem">The Problem</h3>

<p>The sad, sorry truth of the matter is that macOS VoiceOver, in its current state, has a lot of problems. It should only be used to confirm that it can operate the experience the way Windows-based screen readers can.</p>

<p>This means testing on Windows with NVDA or JAWS will <strong>create an experience that is far more accurate to what most people who use screen readers on a laptop or desktop will experience</strong>.</p>

<h3 id="dealing-with-the-problem">Dealing With The Problem</h3>

<p>Because of this situation, I heavily encourage a workflow that involves:</p>

<ol>
<li>Creating an experience’s underlying markup,</li>
<li>Testing it with NVDA or JAWS to set up baseline expectations,</li>
<li>Testing it with macOS VoiceOver to identify what doesn’t work as expected.</li>
</ol>

<p>Most of the time, I find myself having to <a href="#declaring-an-aria-role-on-something-that-already-uses-that-role-implicitly-does-not-make-it-extra-accessible">declare redundant ARIA on the semantic HTML I write</a> in order to address missed expected announcements for macOS VoiceOver.</p>

<p><strong>macOS VoiceOver testing is still important to do</strong>, as it is not the fault of the person who uses macOS VoiceOver to get what they need, and we should ensure they can still have access.</p>

<p>You can use apps like <a href="https://www.virtualbox.org/">VirtualBox</a> and <a href="https://www.microsoft.com/en-us/evalcenter/evaluate-windows-11-enterprise">Windows evaluation Virtual Machines</a> to use Windows in your macOS development environment. Services like <a href="https://assistivlabs.com/">AssistivLabs</a> also make on-demand, preconfigured testing easy.</p>

<p><strong>What About iOS VoiceOver?</strong></p>

<p>Despite sharing the same name, <a href="https://support.apple.com/guide/iphone/turn-on-and-practice-voiceover-iph3e2e415f/ios">VoiceOver on iOS</a> is a completely different animal. As software, it is separate from its desktop equivalent and also enjoys <a href="https://webaim.org/projects/screenreadersurvey10/#mobileplatforms">a whopping 70.6% usage share</a>.</p>

<p>With this knowledge, know that it’s also important to <strong>test the ARIA you write on mobile</strong> to make sure it works as intended.</p>

<h2 id="you-can-style-aria">You Can Style ARIA</h2>

<p>ARIA attributes can be targeted via CSS the way other HTML attributes can. Consider this HTML markup for the main navigation portion of a small e-commerce site:</p>

<pre><code class="language-html">&lt;nav aria-label="Main"&gt;
  &lt;ul&gt;
    &lt;li&gt;
      &lt;a href="/home/"&gt;Home&lt;/a&gt;
      &lt;a href="/products/"&gt;Products&lt;/a&gt;
      &lt;a aria-current="true" href="/about-us/"&gt;About Us&lt;/a&gt;
      &lt;a href="/contact/"&gt;Contact&lt;/a&gt;
    &lt;/li&gt;
  &lt;/ul&gt;
&lt;/nav&gt;
</code></pre>

<p>The presence of <code>aria-current=&quot;true&quot;</code> on the “About Us” link will tell assistive technology to <a href="https://tink.uk/using-the-aria-current-attribute/">announce that it is the current part of the site someone is on</a> if they are navigating through the main site navigation.</p>

<p>We can also tie that indicator of being the current part of the site into something that is shown visually. Here’s how you can target the attribute in CSS:</p>

<pre><code class="language-css">nav[aria-label="Main"] [aria-current="true"] {
  border-bottom: 2px solid #ffffff;
}
</code></pre>

<p>This is <strong>an incredibly powerful way to</strong> <a href="https://css-tricks.com/user-facing-state/"><strong>tie application state to user-facing state</strong></a>. Combine it with modern CSS like <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:has"><code>:has()</code></a> and <a href="https://developer.chrome.com/docs/web-platform/view-transitions">view transitions</a> and you have the ability to create robust, sophisticated UI with less reliance on JavaScript.</p>

<h2 id="you-can-also-use-aria-when-writing-ui-tests">You Can Also Use ARIA When Writing UI Tests</h2>

<p><a href="https://en.wikipedia.org/wiki/Software_testing">Tests</a> are great. They help guarantee that the code you work on will continue to do what you intended it to do.</p>

<p>A lot of web UI-based testing will use the presence of classes (e.g., <code>.is-expanded</code>) or data attributes (ex, <code>data-expanded</code>) to verify a UI’s existence, position and states. These types of selectors also have a far greater likelihood to be changed as time goes on when compared to semantic code and ARIA declarations.</p>

<p>This is something my coworker Cam McHenry touches on in his great post, <a href="https://camchenry.com/blog/how-i-write-accessible-playwright-tests">“How I write accessible Playwright tests”</a>. Consider this piece of <a href="https://playwright.dev/">Playwright</a> code, which checks for the presence of a button that toggles open an edit menu:</p>

<div class="break-out">
<pre><code class="language-javascript">// Selects an element with a role of `button` 
// that has an accessible name of "Edit"
const editMenuButton = await page.getByRole('button', { name: "Edit" });

// Requires the edit button to have a property 
// of `aria-haspopup` with a value of `true`
expect(editMenuButton).toHaveAttribute('aria-haspopup', 'true');
</code></pre>
</div>

<p>The test selects UI based on outcome rather than appearance. That’s <strong>a far more reliable way to target things in the long-term</strong>.</p>

<p>This all helps to create a virtuous feedback cycle. It enshrines semantic HTML and ARIA’s presence in your front-end UI code, which helps to guarantee accessible experiences don’t regress. Combining this with styling, you have a <strong>powerful, self-contained system for building robust, accessible experiences</strong>.</p>

<h2 id="aria-is-ultimately-about-caring-about-people">ARIA Is Ultimately About Caring About People</h2>

<p>Web accessibility can be about enabling important things like scheduling medical appointments. It is also about fun things like chatting with your friends. It’s also used for every web experience that lives in between.</p>

<p>Using semantic HTML &mdash; supplemented with a judicious application of ARIA &mdash; helps you enable these experiences. To sum things up, ARIA:</p>

<ul>
<li>Has been around for a long time, and its spirit reflects the era in which it was first created;</li>
<li>Has a governing taxonomy, vocabulary, and rules for use and is declared in the same way HTML attributes are;</li>
<li>Is mostly used for dynamically updating things, controlled via JavaScript;</li>
<li>Has highly specific use cases in mind for each of its roles;</li>
<li>Fails silently if mis-authored;</li>
<li>Only exposes the presence of something to assistive technology and does not confer interactivity;</li>
<li>Requires input from the web browser, but also the operating system, in order for assistive technology to use it;</li>
<li>Has a range of actual support, complicated by the more of it you use;</li>
<li>Has some things to watch out for, namely <code>aria-label</code>, the ARIA Authoring Practices Guide, and macOS VoiceOver support;</li>
<li>Can also be used for things like visual styling and writing resilient tests;</li>
<li>Is best evaluated by using actual assistive technology.</li>
</ul>

<p>Viewed one way, ARIA is arcane, full of misconceptions, and fraught with potential missteps. Viewed another, ARIA is a beautiful and elegant way to programmatically communicate the interactivity and state of a user interface.</p>

<p>I choose the second view. At the end of the day, using ARIA helps to <strong>ensure that disabled people can use a web experience the same way everyone else can</strong>.</p>

<p><em>Thank you to <a href="https://adrianroselli.com/">Adrian Roselli</a> and <a href="https://janmaarten.com/">Jan Maarten</a> for their feedback.</em></p>

<h3 id="further-reading">Further Reading</h3>

<ul>
<li>“<a href="https://www.lullabot.com/articles/what-heck-aria-beginners-guide-aria-accessibility">What the Heck is ARIA? A Beginner’s Guide to ARIA for Accessibility</a>,” Kat Shaw</li>
<li>“<a href="https://www.smashingmagazine.com/2015/03/web-accessibility-with-accessibility-api/">Accessibility APIs: A Key To Web Accessibility</a>,” Léonie Watson &amp; Chaals McCathie Nevile</li>
<li>“<a href="https://alistapart.com/article/semantics-to-screen-readers/">Semantics to Screen Readers</a>,” Melanie Richards</li>
<li>“<a href="https://www.tpgi.com/what-aria-does-not-do/">What ARIA does not do</a>,” Steve Faulkner</li>
<li>“<a href="https://html5accessibility.com/stuff/2024/07/15/what-aria-still-does-not-do/">What ARIA still does not do</a>,” stevef</li>
<li>“<a href="https://www.deque.com/blog/apg-support-tables-why-they-matter/">APG support tables &mdash; why they matter</a>,” Michael Fairchild</li>
<li>“<a href="https://adrianroselli.com/2023/02/aria-vs-html.html">ARIA vs HTML</a>,” Adrian Roselli</li>
</ul>

<div class="signature">
  <img src="https://www.smashingmagazine.com/images/logo/logo--red.png" alt="Smashing Editorial" width="35" height="46" loading="lazy" decoding="async" />
  <span>(gg, yk)</span>
</div>


              </article>
            </body>
          </html>
        ]]></content:encoded></item></channel></rss>