Aaron Gustafson
banner
aaron.front-end.social.ap.brid.gy
Aaron Gustafson
@aaron.front-end.social.ap.brid.gy
That #ProgressiveEnhancement & #accessibility guy. Working to make the #Web (and world) a little more #Inclusive every day.

Living on unceded Coast Salish land in […]

🌉 bridged from ⁂ https://front-end.social/@Aaron, follow @ap.brid.gy to interact
Pinned
Start building web components the right way with this production-ready template. https://www.aaron-gustafson.com/notebook/a-web-component-starter-template/
A Production-Ready Web Component Starter Template
Creating a new web component from scratch involves a lot of boilerplate—testing setup, build configuration, linting, CI/CD, documentation structure, and more. After building — and refining/rebuilding — numerous web components, I’ve distilled all that work into a starter template that lets you focus on your component’s functionality rather than project setup. The Web Component Starter Template is based on the architecture and patterns I’ve refined across my web component work, incorporating Google’s Custom Element Best Practices and advice from other web components practitioners including the always-brilliant Dave Rupert. ## # What’s included The template provides everything you need to create a production-ready web component: * **Interactive setup wizard** that scaffolds everything for your component. * **Multiple import patterns** supporting both auto-define and manual registration. * **Demo pages** for development, documentation, and CDN examples. * **Code quality tools** including ESLint and Prettier with sensible defaults. * **Modern testing setup** with Vitest, Happy DOM, and coverage reporting. * **CI/CD workflows** for GitHub Actions with automated testing and npm publishing. * **Publishing ready** with proper npm package configuration and OIDC support. ## # Quick start with interactive setup Getting started is straightforward. If you’re a GitHub user, you can create a new repository directly from the template. Alternatively, clone it locally: git clone https://github.com/aarongustafson/web-component-starter.git my-component cd my-component npm install npm run setup The setup wizard asks for your component name and description, then automatically: * Renames all files based on your component name, * Updates all code and configuration templates with your details, * Generates a proper README from the included template, * Cleans up all template-specific files, and * Initializes the git repository. You’re left with a fully scaffolded repository, ready for you to develop your component. ## # Flexible import patterns One of the key features is support for multiple registration patterns. Users of your component can choose what works best: **Manual registration for full control:** import { ComponentNameElement } from '@yourscope/component-name'; customElements.define('my-custom-name', ComponentNameElement); **Auto-define for convenience:** import '@yourscope/component-name/define.js'; **Or call the helper directly:** import { defineComponentName } from '@yourscope/component-name/define.js'; defineComponentName(); The auto-define approach includes guards to ensure it only runs in browser environments and checks if `customElements` is available, making it safe for server-side rendered (SSR) scenarios. ## # Testing made easy The template includes a comprehensive testing setup using Vitest: import { describe, it, expect } from 'vitest'; describe('MyComponent', () => { it('should render', () => { const el = document.createElement('my-component'); expect(el).toBeInstanceOf(HTMLElement); }); }); Happy DOM provides a lightweight browser environment, and the included scripts support: * Watch mode for development: `npm test` * Single run for CI: `npm run test:run` * Interactive UI: `npm run test:ui` * Coverage reports: `npm run test:coverage` ## # Automated publishing with OIDC The template is configured for secure automated publishing to npm using OpenID Connect (OIDC), which is more secure than long-lived tokens. After you manually publish the first version and configure OIDC on npm, create a GitHub release and the workflow handles publishing automatically. Manual publishing is still supported if you prefer that approach. ## # Following best practices The template bakes in best practices from the start: * Shadow DOM with proper encapsulation * Custom Elements v1 API * Reflection of properties to attributes * Lifecycle callbacks used appropriately * Accessible patterns and ARIA support * Progressive enhancement approach The included `WEB-COMPONENTS-BEST-PRACTICES.md` document explains the reasoning behind each pattern, making it a learning resource as well as a starter template. ## # Why I built this After creating components like form-obfuscator, tabbed-interface, and several others, I found myself copying and adapting the same project structure each time. This template captures those patterns so I — and now you — can start building components faster. If you build something with it, I’d love to hear about it!
www.aaron-gustafson.com
So proud of the OneCourt team for their work in bringing more leisure opportunities to the Blind & low vision community. https://apnews.com/article/nfl-blind-fans-super-bowl-6daf12a08127c46c23dab6100a659681
Some blind fans to experience Super Bowl with tactile device that tracks ball
Some blind and low-vision fans will have unprecedented access to the Super Bowl thanks to a tactile device that tracks the ball, vibrates on key plays and provides real-time audio.
apnews.com
February 7, 2026 at 12:57 AM
Add repeatable form field groups with automatic numbering and native form participation. https://www.aaron-gustafson.com/notebook/repeatable-form-fields-made-simple/
Repeatable Form Fields Made Simple
Sometimes you need users to provide multiple instances of the same information—multiple email addresses, phone numbers, team members, or emergency contacts. The `form-repeatable` web component makes this straightforward, handling field duplication, automatic renumbering, and seamless form submission via the ElementInternals API. All you need to do is provide a single field group and the component handles the rest: <form> <form-repeatable> <div> <label for="stop-1">Stop 1</label> <input id="stop-1" type="text" name="stops[]"> </div> </form-repeatable> </form> The `form-repeatable` component treats its first child as a template and injects a `button` that allows users to repeat the field. When users click “Add Another” (the default “add” button text), the following happens: 1. The template is cloned, 2. Any numbers are auto-incremented (“Stop 1” → “Stop 2”, `stop-1` → `stop-2`), 3. A new group is added to the component, 4. A “remove” button is added when there’s more than the minimum number of groups (1 by default), and 5. Form values update automatically via `ElementInternals`. That last piece is crucial. The plugin is a fully-participating member in the parent form: * All inputs are collected and submitted automatically * Values are added to `FormData` * In-built form reset is respected * A form’s disabled state is respected No special handling required — it works like any native form control. ## # Need customized buttons? You bet! If you don’t like the default text or your site is in another language — no biggie! You can define your own button labels using the `add-label` and `remove-label` attributes: <form-repeatable add-label="Add Another Item" remove-label="Delete"> <div> <label for="item-1">Item 1</label> <input id="item-1" type="text" name="items[]" /> </div> </form-repeatable> With that simple change, the add button reads “Add Another Item” and each remove button reads “Delete”. To improve the experience for screen reader users, the `remove-label` value is combined with the associated label/legend to create accessible names like “Delete Item 1” which is far more helpful. ## # Already have values to show? No problem. If your form needs to start with multiple groups already filled in, just provide them as child elements: <form> <form-repeatable min="2"> <div> <label for="phone-1">Phone 1</label> <input id="phone-1" type="tel" name="phones[]" value="555-0100"> </div> <div> <label for="phone-2">Phone 2</label> <input id="phone-2" type="tel" name="phones[]" value="555-0101"> </div> <div> <label for="phone-3">Phone 3</label> <input id="phone-3" type="tel" name="phones[]"> </div> </form-repeatable> </form> All the children will become groups managed by the component and their existing values are preserved. Perfect progressive enhancement! ## # Need to do something a little more complex? I got you. You’re not limited to repeating a single field. Each group can contain multiple, related fields. Here’s an example with a `fieldset` for guest information: <form-repeatable> <fieldset> <legend>Guest 1</legend> <label for="guest-name-1">Name</label> <input id="guest-name-1" type="text" name="guest-name-1"> <label for="guest-email-1">Email</label> <input id="guest-email-1" type="email" name="guest-email-1"> </fieldset> </form-repeatable> When this gets picked up by the component, the whole `fieldset` will become the template. When users add new Guests, all of the numeric values — whether in text or attributes — increment automatically when new groups are added. So in this case, the `legend` will update, as will the `for` attribute on the `label` and `id` and `name` attributes on the `input`. ## # Need to constrain the responses? You got it. Use the `min` and `max` attributes to control the number of allowed groups: <form-repeatable min="2" max="5" add-label="Add Team Member" remove-label="Remove" > <div> <label for="member-1">Team Member 1</label> <input id="member-1" type="text" name="members[]" /> </div> </form-repeatable> This creates a component that: * Starts with 1 member, * Requires adding new members until the `min` threshold (2) is met, * Cannot have fewer than 2 team members, * Cannot have more than 5 team members, and * Uses custom button labels. The remove buttons are not shown when at the minimum threshold (1 by default) and the add button disappears when you hit the maximum. ## # Prefer an explicit `template`? Bring it! This component can accept a `template` element containing the fields you want to repeat. Just drop in `{n}` placeholders where you want the sequential numbers to appear: <form-repeatable> <template> <div> <label for="email-{n}">Email {n}</label> <input id="email-{n}" type="email" name="emails[]"> </div> </template> </form-repeatable> When hoisted into the component, the `template` element is removed from the light DOM and used internally. ## # Here’s what you need to know about styling it The component uses Shadow DOM to encapsulate its internal structure, but you can style it using CSS parts and custom properties. It also adopts your global styles automatically. The component uses CSS Grid by default: * **Two columns** : Content in column 1, remove buttons aligned inline end in column 2 * **Subgrid** : Each group uses `subgrid` to align with parent grid * **Add button** : Appears below all groups You can use CSS parts to style the buttons and field groups. Here are some examples: /* Style all buttons */ form-repeatable::part(button) { padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; } /* Style the add button */ form-repeatable::part(add-button) { background: #28a745; color: white; } /* Style remove buttons */ form-repeatable::part(remove-button) { background: #dc3545; color: white; } /* Customize the grid layout */ form-repeatable::part(groups) { grid-template-columns: 1fr auto; gap: 1rem; } /* Style each group */ form-repeatable::part(group) { padding: 1rem; background: #f8f9fa; border-radius: 4px; margin-bottom: 0.5rem; } Available parts: * `groups` - Container for all groups (CSS grid by default) * `group` - Each repeatable group wrapper * `content` - Container for group’s fields * `group-controls` - Container for the remove button * `controls` - Container for the add button * `button` - All buttons * `add-button` - The add button * `remove-button` - All remove buttons ## # Want to keep a watchful eye? You’re extra, but sure. You can listen for when groups are added or removed and run your own custom code: const repeatable = document.querySelector("form-repeatable"); repeatable.addEventListener("form-repeatable:added", (event) => { console.log("Group added. Total groups:", event.detail.groupCount); }); repeatable.addEventListener("form-repeatable:removed", (event) => { console.log("Group removed. Total groups:", event.detail.groupCount); }); ## # Go with the progressive enhancement flow If JavaScript fails, users see the initial field group(s) and can fill them in. They can’t add more, but nothing breaks. Make sure your minimum count accommodates users without JavaScript. ## # Demo Explore the demo with various examples: ## # Grab it now Check out the project on GitHub. Install via npm: npm install @aarongustafson/form-repeatable Import and go: import "@aarongustafson/form-repeatable"; This single component instance manages all your repeatable field groups with native form participation — no framework required.
www.aaron-gustafson.com
January 31, 2026 at 1:04 AM
Reposted by Aaron Gustafson
BBC: 'Trump says that the US is going to be "strongly involved" in Venezuela's oil industry moving forward.' - hang on, I thought it was about drugs??
January 3, 2026 at 2:30 PM
Start building web components the right way with this production-ready template. https://www.aaron-gustafson.com/notebook/a-web-component-starter-template/
A Production-Ready Web Component Starter Template
Creating a new web component from scratch involves a lot of boilerplate—testing setup, build configuration, linting, CI/CD, documentation structure, and more. After building — and refining/rebuilding — numerous web components, I’ve distilled all that work into a starter template that lets you focus on your component’s functionality rather than project setup. The Web Component Starter Template is based on the architecture and patterns I’ve refined across my web component work, incorporating Google’s Custom Element Best Practices and advice from other web components practitioners including the always-brilliant Dave Rupert. ## # What’s included The template provides everything you need to create a production-ready web component: * **Interactive setup wizard** that scaffolds everything for your component. * **Multiple import patterns** supporting both auto-define and manual registration. * **Demo pages** for development, documentation, and CDN examples. * **Code quality tools** including ESLint and Prettier with sensible defaults. * **Modern testing setup** with Vitest, Happy DOM, and coverage reporting. * **CI/CD workflows** for GitHub Actions with automated testing and npm publishing. * **Publishing ready** with proper npm package configuration and OIDC support. ## # Quick start with interactive setup Getting started is straightforward. If you’re a GitHub user, you can create a new repository directly from the template. Alternatively, clone it locally: git clone https://github.com/aarongustafson/web-component-starter.git my-component cd my-component npm install npm run setup The setup wizard asks for your component name and description, then automatically: * Renames all files based on your component name, * Updates all code and configuration templates with your details, * Generates a proper README from the included template, * Cleans up all template-specific files, and * Initializes the git repository. You’re left with a fully scaffolded repository, ready for you to develop your component. ## # Flexible import patterns One of the key features is support for multiple registration patterns. Users of your component can choose what works best: **Manual registration for full control:** import { ComponentNameElement } from '@yourscope/component-name'; customElements.define('my-custom-name', ComponentNameElement); **Auto-define for convenience:** import '@yourscope/component-name/define.js'; **Or call the helper directly:** import { defineComponentName } from '@yourscope/component-name/define.js'; defineComponentName(); The auto-define approach includes guards to ensure it only runs in browser environments and checks if `customElements` is available, making it safe for server-side rendered (SSR) scenarios. ## # Testing made easy The template includes a comprehensive testing setup using Vitest: import { describe, it, expect } from 'vitest'; describe('MyComponent', () => { it('should render', () => { const el = document.createElement('my-component'); expect(el).toBeInstanceOf(HTMLElement); }); }); Happy DOM provides a lightweight browser environment, and the included scripts support: * Watch mode for development: `npm test` * Single run for CI: `npm run test:run` * Interactive UI: `npm run test:ui` * Coverage reports: `npm run test:coverage` ## # Automated publishing with OIDC The template is configured for secure automated publishing to npm using OpenID Connect (OIDC), which is more secure than long-lived tokens. After you manually publish the first version and configure OIDC on npm, create a GitHub release and the workflow handles publishing automatically. Manual publishing is still supported if you prefer that approach. ## # Following best practices The template bakes in best practices from the start: * Shadow DOM with proper encapsulation * Custom Elements v1 API * Reflection of properties to attributes * Lifecycle callbacks used appropriately * Accessible patterns and ARIA support * Progressive enhancement approach The included `WEB-COMPONENTS-BEST-PRACTICES.md` document explains the reasoning behind each pattern, making it a learning resource as well as a starter template. ## # Why I built this After creating components like form-obfuscator, tabbed-interface, and several others, I found myself copying and adapting the same project structure each time. This template captures those patterns so I — and now you — can start building components faster. If you build something with it, I’d love to hear about it!
www.aaron-gustafson.com
January 2, 2026 at 12:14 AM
Add fullscreen controls to videos and iframes with progressive enhancement. One wrapper, zero hassle. https://www.aaron-gustafson.com/notebook/fullscreen-video-and-iframes-made-easy/
Fullscreen Video and Iframes Made Easy
Adding fullscreen capabilities to videos and embedded iframes shouldn’t require wrestling with prefixed APIs or managing focus states. The `fullscreen-control` web component handles all of that for you — just wrap it around the element. The component handles the rest as a discrete progressive enhancement. ## # Easy-peasy Here’s a simple example using a `video` element: <fullscreen-control> <video src="video.mp4"></video> </fullscreen-control> With that in place, the component * Adds a styleable button for launching fullscreen control over the contained element, * Handles browser prefixes as needed, * Manages focus automatically, * Rigs up the necessary keyboard events (e.g. `Escape` to exit), and * Assigns the relevant ARIA attributes. The component uses light DOM, so your `video` stays in the regular DOM tree and all your existing CSS continues to work. ## # Fullscreen iframes Need to embed a YouTube video, slide deck, or code demo? The component works with `iframe` elements too: <fullscreen-control> <iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315" title="YouTube video player" > </iframe> </fullscreen-control> The component automatically adds the necessary `allow="fullscreen"` and `allowfullscreen` attributes, including prefixed versions for broader compatibility. ## # Customizable `button` text You can change the `button` label to match your site’s language or writing style by setting the `button-text` attribute: <fullscreen-control button-text="全画面表示"> <video src="video.mp4"></video> </fullscreen-control> The default button label is “View fullscreen,” but you can use this attribute to customize it to anything you like. You can even dynamically inject the accessible name of the contained element, using the `{name}` token. For example: <fullscreen-control button-text="View {name} fullscreen"> <video src="video.mp4" aria-label="Product demo"></video> </fullscreen-control> This creates a `button` with the text “View Product demo fullscreen”. The component looks for `aria-label`, `title`, or other native naming on the wrapped element and uses that to make the `button` contextual. ## # Distinct screen reader labels If you want the visible label and accessible button name to differ, use the `button-label` attribute. Like `button-text`, it can also inject the accessible name of the controlled element using the `{name}` token: <fullscreen-control button-text="Fullscreen" button-label="View {name} in fullscreen mode" > <iframe src="https://example.com" title="Product teaser"> </iframe> </fullscreen-control> This code will generate a `button` that visually reads “Fullscreen”, but is announced as “View Product teaser in fullscreen mode” to screen readers. In mode cases, `button-text` will suffice, but this option is available if you need to distinguish the buttons of multiple fullscreen controls from one another and don’t have visual space to display their accessible names. ## # Focus management If users activate fullscreen using the button, focus will automatically return to the button upon exiting fullscreen. This ensures keyboard users don’t lose their place. ## # Need more control? Want to manage the component yourself? The component exposes three methods: const control = document.querySelector("fullscreen-control"); // Enter fullscreen await control.enterFullscreen(); // Exit fullscreen await control.exitFullscreen(); // Toggle fullscreen state control.toggleFullscreen(); These handle all the browser prefixes and error handling for you. There are also a set of events you can tap into when the fullscreen state changes: const control = document.querySelector("fullscreen-control"); control.addEventListener("fullscreen-control:enter", () => { console.log("Entered fullscreen mode"); }); control.addEventListener("fullscreen-control:exit", () => { console.log("Exited fullscreen mode"); }); These events give you the ability to pause other media, track analytics, and the like. ## # Style the button Since the component uses light DOM, you can style the button directly with CSS: fullscreen-control button { background: #ff6b6b; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 20px; font-weight: bold; } fullscreen-control button:hover { background: #ff5252; } The button is positioned absolutely by default (top-right corner), but you can adjust this with CSS custom properties: fullscreen-control { --fullscreen-control-button-inset-block-start: 1rem; --fullscreen-control-button-inset-inline-end: 1rem; } This uses logical properties, so it adapts automatically to different writing modes. ## # Installation Install via npm: npm install @aarongustafson/fullscreen-control Then import it in your JavaScript: import "@aarongustafson/fullscreen-control/define.js"; Or load it from a CDN for quick prototyping: <script type="module"> import { defineFullscreenControl } from "https://unpkg.com/@aarongustafson/fullscreen-control@latest/define.js?module"; defineFullscreenControl(); </script> ## # Browser support The component uses modern web standards (Custom Elements v1, ES Modules) and handles browser-prefixed fullscreen APIs internally. For older browsers, you may need polyfills, but the component gracefully handles missing APIs with console warnings rather than breaking your page. ## # Demo and source code Check out the live demo to see all the features in action, or grab the code from GitHub.
www.aaron-gustafson.com
December 30, 2025 at 12:55 AM
Add fullscreen controls to videos and iframes with progressive enhancement. One wrapper, zero hassle. https://www.aaron-gustafson.com/notebook/fullscreen-video-and-iframes-made-easy/
Fullscreen Video and Iframes Made Easy
Adding fullscreen capabilities to videos and embedded iframes shouldn’t require wrestling with prefixed APIs or managing focus states. The `fullscreen-control` web component handles all of that for you — just wrap it around the element. The component handles the rest as a discrete progressive enhancement. ## # Easy-peasy Here’s a simple example using a `video` element: <fullscreen-control> <video src="video.mp4"></video> </fullscreen-control> With that in place, the component * Adds a styleable button for launching fullscreen control over the contained element, * Handles browser prefixes as needed, * Manages focus automatically, * Rigs up the necessary keyboard events (e.g. `Escape` to exit), and * Assigns the relevant ARIA attributes. The component uses light DOM, so your `video` stays in the regular DOM tree and all your existing CSS continues to work. ## # Fullscreen iframes Need to embed a YouTube video, slide deck, or code demo? The component works with `iframe` elements too: <fullscreen-control> <iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315" title="YouTube video player" > </iframe> </fullscreen-control> The component automatically adds the necessary `allow="fullscreen"` and `allowfullscreen` attributes, including prefixed versions for broader compatibility. ## # Customizable `button` text You can change the `button` label to match your site’s language or writing style by setting the `button-text` attribute: <fullscreen-control button-text="全画面表示"> <video src="video.mp4"></video> </fullscreen-control> The default button label is “View fullscreen,” but you can use this attribute to customize it to anything you like. You can even dynamically inject the accessible name of the contained element, using the `{name}` token. For example: <fullscreen-control button-text="View {name} fullscreen"> <video src="video.mp4" aria-label="Product demo"></video> </fullscreen-control> This creates a `button` with the text “View Product demo fullscreen”. The component looks for `aria-label`, `title`, or other native naming on the wrapped element and uses that to make the `button` contextual. ## # Distinct screen reader labels If you want the visible label and accessible button name to differ, use the `button-label` attribute. Like `button-text`, it can also inject the accessible name of the controlled element using the `{name}` token: <fullscreen-control button-text="Fullscreen" button-label="View {name} in fullscreen mode" > <iframe src="https://example.com" title="Product teaser"> </iframe> </fullscreen-control> This code will generate a `button` that visually reads “Fullscreen”, but is announced as “View Product teaser in fullscreen mode” to screen readers. In mode cases, `button-text` will suffice, but this option is available if you need to distinguish the buttons of multiple fullscreen controls from one another and don’t have visual space to display their accessible names. ## # Focus management If users activate fullscreen using the button, focus will automatically return to the button upon exiting fullscreen. This ensures keyboard users don’t lose their place. ## # Need more control? Want to manage the component yourself? The component exposes three methods: const control = document.querySelector("fullscreen-control"); // Enter fullscreen await control.enterFullscreen(); // Exit fullscreen await control.exitFullscreen(); // Toggle fullscreen state control.toggleFullscreen(); These handle all the browser prefixes and error handling for you. There are also a set of events you can tap into when the fullscreen state changes: const control = document.querySelector("fullscreen-control"); control.addEventListener("fullscreen-control:enter", () => { console.log("Entered fullscreen mode"); }); control.addEventListener("fullscreen-control:exit", () => { console.log("Exited fullscreen mode"); }); These events give you the ability to pause other media, track analytics, and the like. ## # Style the button Since the component uses light DOM, you can style the button directly with CSS: fullscreen-control button { background: #ff6b6b; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 20px; font-weight: bold; } fullscreen-control button:hover { background: #ff5252; } The button is positioned absolutely by default (top-right corner), but you can adjust this with CSS custom properties: fullscreen-control { --fullscreen-control-button-inset-block-start: 1rem; --fullscreen-control-button-inset-inline-end: 1rem; } This uses logical properties, so it adapts automatically to different writing modes. ## # Installation Install via npm: npm install @aarongustafson/fullscreen-control Then import it in your JavaScript: import "@aarongustafson/fullscreen-control/define.js"; Or load it from a CDN for quick prototyping: <script type="module"> import { defineFullscreenControl } from "https://unpkg.com/@aarongustafson/fullscreen-control@latest/define.js?module"; defineFullscreenControl(); </script> ## # Browser support The component uses modern web standards (Custom Elements v1, ES Modules) and handles browser-prefixed fullscreen APIs internally. For older browsers, you may need polyfills, but the component gracefully handles missing APIs with console warnings rather than breaking your page. ## # Demo and source code Check out the live demo to see all the features in action, or grab the code from GitHub.
www.aaron-gustafson.com
December 29, 2025 at 5:23 PM
Want API-driven autocomplete suggestions in your forms? Here’s a web component that makes it happen. https://www.aaron-gustafson.com/notebook/dynamic-datalist-autocomplete-from-an-api/
Dynamic Datalist: Autocomplete from an API
HTML’s `datalist` element provides native autocomplete functionality, but it’s entirely static—you have to know all the options up front. The `dynamic-datalist` web component solves this by fetching suggestions from an API endpoint as users type, giving you the benefits of native autocomplete with the flexibility of dynamic data. This component is a modern replacement for my old jQuery predictive typing plugin. I’ve reimagined it as a standards-based web component. ## # Basic usage To use the component, wrap it around your `input` field and specify an endpoint: <dynamic-datalist endpoint="/api/search"> <label for="search">Search <input type="text" id="search" name="search" placeholder="Type to search..." > </label> </dynamic-datalist> As users type, the component makes GET requests to that endpoint, passing in the typed value as the “query” parameter (e.g., `/api/search?query=WHAT_THE_USER_TYPED`). The response fromm the endpoint is used to populates a dynamic `datalist` element with the results. The structure of the response should be JSON with an `options` array of string values: { "options": [ "option 1", "option 2", "option 3" ] } ## # How it works Under the hood, the component: 1. Adopts (or creates) a `datalist` element for your `input`, 2. Listens for “input” events, 3. Debounces requests (waiting at least 250ms) to avoid overwhelming your API, 4. Sends requests to your endpoint with the current value of the `input`, 5. Reads back the JSON response, 6. Updates the `datalist` `option` elements, and 7. Dispatches the update event. All of this happens transparently—users just see autocomplete suggestions appearing as they type. ## # Need POST? You can change the submission method via the `method` attribute: <dynamic-datalist endpoint="/api/lookup" method="post"> <label for="lookup">Lookup <input type="text" id="lookup" name="lookup"> </label> </dynamic-datalist> This sends a POST request with a JSON body: `{ "query": "..." }`. Currently GET and POST are supported, but I could add more if folks want them. ## # Custom variable names As I mentioned, the component uses “query” as the parameter name by default, but you can easily change it via the `key` attribute: <dynamic-datalist endpoint="/api/terms" key="term"> <label for="search">Term search <input type="text" id="search" name="term"> </label> </dynamic-datalist> This sends the GET request `/api/terms?term=...`. ## # Working with existing datalists If your `input` already has a `datalist` defined, the component will inherit it and replace the existing options with the fetched results, which makes for a nice progressive enhancement: <dynamic-datalist endpoint="/api/cities"> <label for="city">City <input type="text" id="city" list="cities-list" placeholder="Type a city…" > </label> <datalist id="cities-list"> <option>New York</option> <option>Los Angeles</option> <option>Chicago</option> </datalist> </dynamic-datalist> Users see the pre-populated cities immediately, and as they type, API results supplement the list. If JavaScript fails or the web component doesn’t load, users still get the static options. Nothing breaks. ## # Event handling If you want to tap into the component’s event system, it fires three custom events: * `dynamic-datalist:ready` - Fired when the component initializes * `dynamic-datalist:update` - Fired when the `datalist` is updated with new options * `dynamic-datalist:error` - Fired when an error occurs fetching data const element = document.querySelector('dynamic-datalist'); element.addEventListener('dynamic-datalist:ready', (e) => { console.log('Component ready:', e.detail); }); element.addEventListener('dynamic-datalist:update', (e) => { console.log('Options updated:', e.detail.options); }); element.addEventListener('dynamic-datalist:error', (e) => { console.error('Error:', e.detail.error); }); Each event provides helpful `detail` objects with references to the `input`, `datalist`, and other relevant data. ## # Demo Check out the demo for live examples (there are also unpkg and ESM builds if you want to test CDN delivery): ## # Grab it The project is available on GitHub. You can also install via npm: npm install @aarongustafson/dynamic-datalist If you go that route, there are a few ways to register the element depending on your build setup: ### # Option 1: Define it yourself import { DynamicDatalistElement } from '@aarongustafson/dynamic-datalist'; customElements.define('dynamic-datalist', DynamicDatalistElement); ### # Option 2: Let the helper guard registration import '@aarongustafson/dynamic-datalist/define.js'; // or, when you need to wait: import { defineDynamicDatalist } from '@aarongustafson/dynamic-datalist/define.js'; defineDynamicDatalist(); ### # Option 3: Drop the helper in via a `<script>` tag <script src="./node_modules/@aarongustafson/dynamic-datalist/define.js" type="module"></script> Regardless of how you register it, there are no framework dependencies—just clean autocomplete powered by your API. As I mentioned, it’s also available via CDNs, such as unpkg too, if you’d prefer to go that route.
www.aaron-gustafson.com
December 16, 2025 at 9:42 PM
Just finished reading “It’s not you, it’s Capitalism” by Malaika Jabali. It’s an approachable introduction to leftist thought and clearly articulates the need for people power to counterbalance the power and wealth of the oligarchs. Well worth a read and […]

[Original post on front-end.social]
December 14, 2025 at 4:28 PM
Want to skip loading images entirely on mobile? Here's a web component that does just that. https://www.aaron-gustafson.com/notebook/lazy-loading-images-based-on-screen-size/
Lazy Loading Images Based on Screen Size
Native lazy loading and `srcset` are great, but they have a limitation: they always load _some_ variant of the image. The `lazy-img` web component takes a different approach—it can completely skip loading images when they don’t meet your criteria, whether that’s screen size, container size, or visibility in the viewport. This is particularly valuable for mobile users on slow connections or limited data plans. If an image is only meaningful on larger screens, why waste their bandwidth loading it at all? ## # The performance benefit Unlike `picture` or `srcset`, which always load some image variant, `lazy-img` can **completely skip loading images** on screens or containers below your specified threshold. Set `min-inline-size="768px"` and mobile users will never download that image at all—saving data and speeding up page loads. Once an image is loaded, however, it remains loaded even if the viewport or container is resized below the threshold. This is intentional—the component prevents unnecessary downloads but doesn’t unload images already in memory. You can control visibility with CSS if needed using the `loaded` and `qualifies` attributes (which we’ll get to shortly). ## # Basic usage The `lazy-img` works pretty much identically to a regular `img` element, with all the attributes you know and love: <lazy-img src="image.jpg" alt="A beautiful image"> </lazy-img> But that’s not very interesting. The real power comes from conditional loading. ## # Container queries (default) Load an image only when its container reaches a minimum width: <lazy-img src="large-image.jpg" alt="Large image" min-inline-size="500px"> </lazy-img> The image loads when the `lazy-img` element’s container reaches 500px wide. This is the default query mode—it uses `ResizeObserver` to watch the container size. ## # Media queries You can lazy load images based on viewport width instead by switching to media query mode: <lazy-img src="desktop-image.jpg" alt="Desktop image" min-inline-size="768px" query="media"> </lazy-img> With this configuration, the image loads when the browser window is at least 768px wide. ## # View mode (scroll-based loading) Load images when they scroll into view using `IntersectionObserver` by switching to the “view” query type: <lazy-img src="image.jpg" alt="Loads when scrolled into view" query="view"> </lazy-img> The default behavior (`view-range-start="entry 0%"`) loads as soon as any part of the image enters the viewport. Control when images load with the `view-range-start` attribute: **Load when 50% visible:** <lazy-img src="image.jpg" alt="Loads when half visible" query="view" view-range-start="entry 50%"> </lazy-img> **Preload before entering viewport:** <lazy-img src="image.jpg" alt="Preloads 200px before visible" query="view" view-range-start="entry -200px"> </lazy-img> This creates a smooth user experience—images are already loaded by the time users scroll to them. ## # Responsive images As with regular images, you can use `srcset` and `sizes` for responsive images: <lazy-img src="image-800.jpg" srcset="image-400.jpg 400w, image-800.jpg 800w, image-1200.jpg 1200w" sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px" alt="Responsive image" min-inline-size="400px"> </lazy-img> The component waits until the conditions are met before loading a real image and the browser takes over from there. ## # Named breakpoints You can also define named breakpoints using CSS custom properties: :root { --lazy-img-mq: small; } @media (min-width: 768px) { :root { --lazy-img-mq: medium; } } @media (min-width: 1024px) { :root { --lazy-img-mq: large; } } Then reference them in your markup: <lazy-img src="image.jpg" alt="Image with named breakpoints" named-breakpoints="medium, large" query="media"> </lazy-img> The image loads when `--lazy-img-mq` matches “medium” or “large”. ## # Preventing layout shift As with regular images, don’t forget to use `width` and `height` attributes to prevent Cumulative Layout Shift (CLS): <lazy-img src="image.jpg" alt="A beautiful image" width="800" height="600" min-inline-size="768px"> </lazy-img> The browser reserves the correct space while the image loads, preventing content from jumping around. ## # State attributes for styling The component provides `loaded` and `qualifies` attributes you can use in CSS: /* Hide images that loaded but no longer meet conditions */ lazy-img[loaded]:not([qualifies]) { display: none; } /* Show a placeholder for images that qualify but haven't loaded */ lazy-img[qualifies]:not([loaded])::before { content: "Loading…"; display: block; padding: 2em; background: #f0f0f0; text-align: center; } ## # Events If you crave control, you can add your own functionality by listening for when images load: const lazyImg = document.querySelector('lazy-img'); lazyImg.addEventListener('lazy-img:loaded', (event) => { console.log('Image loaded:', event.detail.src); }); ## # Performance The component is highly optimized: * **Throttled resize** : Resize events are throttled to prevent excessive checks * **Shared`ResizeObserver`**: Multiple images observing the same container share a single ResizeObserver * **Shared window resize listener** : Media query mode shares a single window resize listener * **Shared`IntersectionObserver`**: View mode with the same `view-range-start` shares an `IntersectionObserver` * **Clean disconnection** : Properly cleans up observers when elements are removed Even with hundreds of `lazy-img` elements on a page, performance remains excellent. ## # Progressive enhancement If JavaScript fails to load, images simply don’t appear (unless using immediate loading mode). This might sound problematic, but for non-critical images—decorative graphics, supplementary screenshots, marketing imagery—it’s often exactly what you want. Your content remains accessible; you just lose the enhancements. For critical images that are part of your content, use standard `img` tags. Use `lazy-img` for conditional enhancements. ## # Demo Explore the demo to see container queries, media queries, scroll-based loading, and more in action: ## # Grab it Check out the project on GitHub. Install via npm: npm install @aarongustafson/lazy-img Import and use: import '@aarongustafson/lazy-img'; Based on my original Easy Lazy Images concept, reimagined as a modern custom element.
www.aaron-gustafson.com
December 10, 2025 at 6:57 PM
Need to obfuscate form field values when they’re not being edited? Here’s a web component for that.

https://www.aaron-gustafson.com/notebook/a-web-component-for-obfuscating-form-fields/
A Web Component for Obfuscating Form Fields
We have the password reveal pattern for passwords, but what about other sensitive fields that need to be readable while editing and obfuscated while at rest? The `form-obfuscator` web component does exactly that. ## # Basic usage Wrap any text field in the component and it will automatically obfuscate the value when the field loses focus: <form-obfuscator> <label for="secret-key-1">What was your first pet’s name?</label> <input type="text" id="secret-key-1" name="secret-key-1"> </form-obfuscator> When users click into the field, they see the actual value. When they click away, it’s replaced with asterisks (*). The real value is preserved in a hidden field for form submission. ## # Custom obfuscation characters If you don’t like asterisks, you can specify any character you like: <form-obfuscator character="•"> <label for="account">Account Number</label> <input type="text" id="account" name="account"> </form-obfuscator> Or get creative: <form-obfuscator character="🤐"> <label for="ssn">Social Security Number</label> <input type="text" id="ssn" name="ssn"> </form-obfuscator> ## # Pattern-based obfuscation Sometimes you want to show part of the value while hiding the rest. The `pattern` attribute lets you specify which characters to keep visible: <form-obfuscator pattern="\d{4}$"> <label for="ssn">Social Security Number</label> <input type="text" id="ssn" name="ssn"> </form-obfuscator> This keeps the last four digits visible while replacing everything else with your obfuscation character. Perfect for Social Security Numbers, credit cards, or phone numbers where showing the last few digits helps users confirm they’ve entered the right value. ## # Limiting displayed characters Use the `maxlength` attribute to cap how many characters appear when obfuscated: <form-obfuscator maxlength="4"> <label for="password">Password</label> <input type="text" id="password" name="password"> </form-obfuscator> Even if the user enters a 20-character value, only four asterisks will be displayed when the field is obfuscated. This prevents giving away information about the length of the information entered. ## # Custom replacement functions For complete control, you can provide a JavaScript function via the `replacer` attribute: <script> window.emailReplacer = function() { var username = arguments[0][1]; var domain = arguments[0][2]; return username.replace(/./g, '*') + domain; } </script> <form-obfuscator pattern="^(.*?)(@.+)$" replacer="return emailReplacer(arguments)"> <label for="email">Email Address</label> <input type="text" id="email" name="email" value="user@example.com"> </form-obfuscator> This example uses a pattern to separate the username from the domain, then obfuscates only the username portion, leaving `@example.com` visible. Here’s another practical example for credit cards: <script> function cardNumberReplacer() { var beginning = arguments[0][1]; var final_digits = arguments[0][2]; return beginning.replace(/\d/g, '*') + final_digits; } </script> <form-obfuscator pattern="^((?:[\d]+\-)+)(\d+)$" replacer="return cardNumberReplacer(arguments)"> <label for="cc">Credit Card</label> <input type="text" id="cc" name="cc" value="1234-5678-9012-3456"> </form-obfuscator> This displays as `****-****-****-3456`, showing only the last group of digits. ## # Combining attributes You can combine these attributes for sophisticated obfuscation patterns: <form-obfuscator pattern="\d{4}$" character="•" maxlength="16"> <label for="card">Credit Card</label> <input type="text" id="card" name="card"> </form-obfuscator> This keeps the last 4 digits visible, uses bullets for obfuscation, and limits the display to 16 characters total. ## # Event handling The component dispatches custom events when values are hidden or revealed: const obfuscator = document.querySelector('form-obfuscator'); obfuscator.addEventListener('form-obfuscator:hide', (e) => { console.log('Field obfuscated:', e.detail.field.value); }); obfuscator.addEventListener('form-obfuscator:reveal', (e) => { console.log('Field revealed:', e.detail.field.value); }); You can access both the visible field and the hidden field through `event.detail.field` and `event.detail.hidden` respectively. ## # How it works The component creates a hidden `input` field to store the actual value for form submission. When the visible field loses focus, it: 1. Copies the current value to the hidden field 2. Applies your obfuscation rules to create the display value 3. Updates the visible field with the obfuscated value 4. Dispatches the `form-obfuscator:hide` event When the field gains focus, it: 1. Restores the real value from the hidden field 2. Updates the visible field 3. Dispatches the `form-obfuscator:reveal` event The source order ensures the hidden field is the one that gets submitted with the form. ## # Progressive enhancement The component makes no assumptions about your markup—it works with any text-style `input` element. If JavaScript fails to load, the field behaves like a normal `input`, which is exactly what you want. Users can still enter and submit values; they just won’t get the obfuscation behavior. ## # Demo I’ve created a comprehensive demo page showing the various configuration options, but here’s a quick CodePen demo too:<p><a href="https://codepen.io/aarongustafson/pen/gbrQddg" rel="noopener" target="_blank">See the Pen</a> ## # Grab it Check out the full project over on GitHub or install via `npm`: npm install @aarongustafson/form-obfuscator Import and use: import '@aarongustafson/form-obfuscator'; No dependencies, just a straightforward way to add field obfuscation to your forms.
www.aaron-gustafson.com
December 6, 2025 at 10:21 PM
“Accessible technology empowers everyone. And with the rise of AI, we’re entering a new era of possibility.”

https://blogs.microsoft.com/accessibility/forrester-research-2025/
blogs.microsoft.com
December 5, 2025 at 5:53 PM
My team (Microsoft Accessibility) has an open internship for legal counsel. If you’re in school and interested in law as it relates to accessibility, give it a look:

https://apply.careers.microsoft.com/careers/job/1970393556631227
December 2, 2025 at 6:24 PM
ARIA Notify looks like it could solve a lot of problems with screen reader announcements by simplifying the process.

https://blogs.windows.com/msedgedev/2025/05/05/creating-a-more-accessible-web-with-aria-notify/
Creating a more accessible web with Aria Notify
We're excited to announce the availability, as a developer and origin trial, of ARIA Notify, a new API that's designed to make web content
blogs.windows.com
November 26, 2025 at 6:47 PM
I think one of LinkedIn’s most underrated features — and it’s an incredibly simple one — is getting to hear a recording of people pronouncing their own name. I use it all the time to help myself say unfamiliar names, prior to hopping on a call.
November 14, 2025 at 5:03 PM
Overheard: Quid Pro Cuomo

🤣
November 5, 2025 at 6:59 AM
Happy Halloween y’all!

We went as songs this year. I’m “Long Legged Larry” by Aesop Rock.

http://youtu.be/us3pCHd8PLs
November 1, 2025 at 12:38 AM
[Death of a child; Palestine]

Rana Majed Al-Muqayad didn't reach one year of age. Innocent, orphaned, she was unaware of her surroundings. Surely, had she grown up, she would have had many dreams and stories. Rana and her mother, Shaimaa Saleh, were killed […]

[Original post on front-end.social]
October 24, 2025 at 9:27 PM
Optimizing Your Codebase for AI Coding Agents
Credit: Aaron Gustafson × Designer I’ve been playing around a bit with GitHub Copilot as an autonomous agent to help with software development. The results have been mixed, but positive overall. I made an interesting discovery when I took the time to read through the agent’s reasoning over a particular task. I thought the task was straightforward, but I was wrong. Watching the agent work was like watching someone try to navigate an unfamiliar room, in complete darkness, with furniture and Lego bricks scattered everywhere. The good news? Most of the issues weren’t actually _code_ problems; they were organizational and documentation problems. The kinds of problems that make tasks hard for humans too. As I watched the agent struggle, I realized that optimizing for AI agents is really just about removing ambiguity and making implicit knowledge explicit. In other words: it’s just good engineering. ## # What did I learn? After reviewing the agent’s execution logs (which read like a stream-of-consciousness diary of confusion), several patterns emerged: ### # 1. **Documentation sprawl is an efficiency killer** The agent spent roughly 40% of its time just trying to figure out which documentation to trust. We had instructions in workflow comments, the README, task specific instructions, and more. In other words, we had no clear source of truth. Pieces of that truth were scattered across multiple files and the docs were inconsistent and — in some cases — contradictory. Sound familiar? It’s the equivalent of having five different “getting started” guides that all got written at different times by different people and nobody bothered to consolidate them. (If you’ve ever worked on a project that’s been around for more than a year with no one in charge of documentation, you know exactly what I’m talking about.) **The fix:** Establish a single source of truth. Ruthlessly. We consolidated everything into one comprehensive guide and updated all references to point _only_ there. Deprecated docs were deleted and/or redirected, as appropriate. No more choose your own adventure. No more guessing. ### # 2. **Agents won’t optimize themselves** Here’s a fun one: the agent ran several full production builds—complete with image processing, template compilation… the whole shebang—just to validate a markdown file was in the right format. These builds took 30-60 seconds. _Each time._ This is like requiring someone to assemble an entire car just to check if the owner’s guide is displaying the right “check engine” symbol. Technically it works, but yikes. **The fix:** Write fast, focused validation scripts. One tool for each job. Tell the agent what the utility is, where to find it, and how to use it. Give it explicit instructions to use utility scripts in lieu of full site builds whenever possible. ### # 3. **Ambiguity breeds confusion (and wasted tokens)** The agent spent 15+ minutes having an internal philosophical debate about whether to process test data. Should it reject it? Accept it? Create a placeholder? The instructions didn’t say, so the agent did what any of us would do: it agonized — or at least feigned agonizing — over the decision and tried to infer intent from context clues. **The fix:** Be explicit about edge cases. We added a dedicated section for handling test form submissions. No more guessing. ## # There’s a pattern here If you squint, all of these issues share a common root cause: **implicit assumptions**. We assumed humans would know to check one doc instead of five. We assumed the difference between validation and building was obvious. We assumed everyone would understand how to handle edge cases. AI agents don‘t — can’t? — make those assumptions. They need explicit instructions, clear boundaries, and unambiguous inputs. Honestly? So do humans. We’re just better at muddling through — or think we are. ## # Early results After implementing these changes, we expect (and early testing confirms): * ~40% reduction in processing time, * ~75% reduction in token usage, and * >80% reduction in confusion and circular reasoning. But here’s the thing: these improvements don’t just help the AI agent. They help _everyone_. The consolidated documentation is easier to navigate. The fast validation scripts are useful for humans too. The explicit edge case handling prevents future questions. ## # The key to reducing toil: excellent docs and tools Optimizing for AI agents isn’t really about AI. It’s about removing ambiguity, eliminating redundancy, and making implicit knowledge explicit. It’s about writing code and documentation that doesn’t require a deep understanding of the project to comprehend. In other words: it’s just good engineering. So if you’re working with AI coding agents — or planning to — invest in your docs and tooling. Don’t think of it as wasted time “writing for robots.” Think of it as paying down documentation debt and building an efficient engineering process. Your future self, your teammates, and the bots will thank you. # # Afterword Interestingly, an AI agent was a particularly useful partner in finding and addressing the technical debt we’d been living with. Sometimes you need a pedantic robot to point out that your house is a mess.
www.aaron-gustafson.com
October 22, 2025 at 12:02 AM
Sometimes you only want a field to show when certain other fields have a (particular) value. The `form-show-if` web component enables that.

https://www.aaron-gustafson.com/notebook/a-web-component-for-conditionally-displaying-fields/
A Web Component for Conditionally Displaying Fields
Building on my recent work in the form utility space, I’ve created a new web component that allows you to conditionally display form fields based on the values of other fields: `form-show-if`. This component tackles a common UX pattern that HTML doesn’t natively support. You know the scenario—you have a form where certain fields should only appear when specific conditions are met. Maybe you want to show shipping address fields only when someone checks “Ship to different address,” or display a text input for “Other” when someone selects that option from a dropdown. This web component makes that setup effortless — and declarative. You set up `form-show-if` like this: <form-show-if conditions="contact_method=phone"> <label for="phone">Phone Number <input type="tel" id="phone" name="phone"> </label> </form-show-if> You wrap any field and its `label` in the component and then declare the conditions under which it should be displayed in the `conditions` attribute. ## # Defining the display conditions Each condition is a key/value pair where the key aligns to the `name` of the field you need to observe and the value is the value that triggers the display. If any value should trigger the display, use an asterisk (`*`) as the value. In the example above, the field will become visible only if — in a theoretical contact method choice — a user chooses “phone” as the method they want used. The `conditions` attribute can be populated with as many dependencies as you need. Multiple conditions are separated by double vertical pipes (`||`), as in this example: <form-show-if conditions="contact_method=phone||contact_method=text_message"> <label for="phone-number">Phone Number <input type="tel" id="phone" name="phone"> </label> </form-show-if> Here the field depends on one of the following conditions being true: 1. the field matching `[name="contact_method"]` has a value of “phone” _or_ 2. the field matching `[name="contact_method"]` has a value of “text_message” If the field you reference doesn’t exist, no errors will be thrown—it will just quietly exit. ## # Customizing the show/hide behavior By default, the component uses the `hidden` attribute to hide the wrapped content when it’s not needed. But you can customize this behavior using CSS classes instead: <form-show-if conditions="shipping-method=express" disabled-class="fade-out" enabled-class="fade-in"> <label for="delivery-date">Express Delivery Date <input type="date" id="delivery-date" name="delivery-date"> </label> </form-show-if> When using custom classes: * **`disabled-class`** is applied when the condition is not met (field should be hidden) * **`enabled-class`** is applied when the condition is met (field should be shown) Both are optional. Just remember that if you define a `disabled-class`, the `hidden` attribute will not be used — you will need to accessibly hide the content yourself. This gives you complete control over the visual presentation. You could use CSS transitions for smooth animations, apply different styling states, or integrate with your existing design system’s utility classes. ## # Handling form state properly The component doesn’t just toggle visibility—it also manages the form state correctly. When fields are hidden, they’re automatically disabled using the `disabled` attribute. If there are any sibling fields in the component, they will be disabled as well. This prevents these fields from being submitted with the form and ensures they don’t interfere with form validation. When conditions are met and fields become visible, they’re re-enabled automatically. This behavior works seamlessly with both native form validation and custom validation scripts. ## # Real-world examples Here are some practical use cases where this component shines: **“Other” option handling:** <fieldset> <legend>How did you hear about us?</legend> <label><input type="radio" name="source" value="google"> Google</label> <label><input type="radio" name="source" value="friend"> Friend</label> <label><input type="radio" name="source" value="other"> Other</label> <form-show-if conditions="source=other"> <label for="source-other">Please specify <input type="text" id="source-other" name="source-other"> </label> </form-show-if> </fieldset> **Specific value matching:** <form-show-if conditions="email=test@example.com"> <label for="debug-info">Debug Information <textarea id="debug-info" name="debug-info"></textarea> </label> <small>This field only shows for test accounts</small> </form-show-if> ## # Progressive enhancement in action Like all good web components, `form-show-if` follows progressive enhancement principles. If JavaScript fails to load or the browser doesn’t support custom elements, your form still works—users just see all the fields all the time. Not ideal for the user experience, but nothing breaks either. The component is lightweight, has no dependencies, and works in all modern browsers. ## # Demo I’ve put together a comprehensive demo showing various use cases and configurations over on GitHub. The demo includes examples of: * Basic show/hide functionality * Multiple condition logic * Custom CSS class integration * Complex form scenarios with radio buttons and checkboxes * Different field grouping approaches ## # Grab it You can view the entire project (and suggest enhancements) over on the form-show-if component’s GitHub repo. The component is available as both a standard script and an ES module, so you can integrate it however works best for your project. Installation is straightforward—just include the script in your page and start using the `form-show-if` element. No build step required, no framework dependencies, just clean, standards-based progressive enhancement.
www.aaron-gustafson.com
October 20, 2025 at 9:10 PM
👍🏻 “When one approach becomes ‘how things are done,’ we unconsciously defend it even when standards would give us a healthier, more interoperable ecosystem.”

https://eisenbergeffect.medium.com/default-isnt-design-24df33272abb
October 16, 2025 at 9:39 PM
I probed an LLM’s responses to HTML code generation prompts to assess its adherence to accessibility best practices. The results showed key areas where better training data is needed.

https://www.aaron-gustafson.com/notebook/identifying-accessibility-data-gaps-in-codegen-models/
Identifying Accessibility Data Gaps in CodeGen Models
Credit: Aaron Gustafson × Designer Late last year, I probed an LLM’s responses to HTML code generation prompts to assess its adherence to accessibility best practices. The results were unsurprisingly disappointing — roughly what I’d expect from a developer aware of accessibility but unsure how to implement it. The study highlighted key areas where training data needs improvement. ## # Why take on this challenge? I get it — you probably rolled your eyes at yet another “AI and accessibility” post. Maybe you think AI-assisted coding is overhyped, environmentally harmful, unreliable, or just plain dangerous for our craft. I share many of those concerns. But here’s the thing: whether we like it or not, codegen models aren’t going anywhere. GitHub Copilot has millions of users, and tools like Claude Code and Cursor are rapidly gaining popularity. So we have a choice: we can complain about the inevitable tide of AI-generated garbage code, or we can get in there and figure out how to make it better — especially when it comes to accessibility. We’re facing a looming wave of inaccessible code that will be extremely difficult to remediate later. The foundation models are already being trained on the collective output of the web’s development community — a community that doesn’t have a high bar high for accessibility already. Codegen models are a massive consultancy staffed with full StackOverflow developers. We need to figure out how to make them part of the solution, not part of the problem. It’s also worth noting that the better we make the output of these models, the fewer bugs will be generated. That, in turn, means fewer accessibility issues to fix later. If we don’t, there are plenty of AI-assisted scanners out there happy to burn the rainforest to find and remediate the bugs after the fact. We risk doubling the environmental impact—once to generate the bug, and again to fix it. That’s not the future I want. The reality here is that the only way to deal with this flood of AI-generated code is to make sure it’s good code in the first place. ## # How did I conduct my research? Rather than relying on anecdotal evidence or cherry-picked examples, I built a systematic approach to evaluate how well LLMs — starting with GPT-4 — generate accessible HTML. The methodology is straightforward but comprehensive: I created a Python testing framework that sent carefully crafted prompts to Azure OpenAI’s GPT 4 model, collected the generated HTML responses, and then manually analyzed these responses for accessibility compliance. Here’s how it works: **Prompt Engineering** : I designed prompts that ask for specific UI components—form fields, navigation menus, interactive elements—without explicitly mentioning accessibility requirements. This gives us a baseline of what the model considers “standard” output. I included one prompt that specifically requested accessibility features to see if the model could improve when guided. I suspected it would often add ARIA attributes without addressing underlying issues, but I wanted to validate that too. **Response Collection** : For each prompt, I generated 10 iterations at high temperature (0.95) to capture the model’s range of responses. Each unique response got saved as an individual HTML file for analysis. **Systematic Analysis** : I manually review each generated code snippet, cataloging accessibility errors, warnings, and missed opportunities. I tried using the LLM as a judge, but even with a detailed rubric, the results were poor. My eval looked specifically for things like: * Improper semantic HTML usage * Missing or incorrect ARIA attributes * Keyboard navigation issues * Screen reader compatibility problems * Form labeling errors When I identified errors, I remediated them and committed the remediated file to the repo with a commit message that included all of the issues and warnings on its own line. **Diff-Based Retesting** : I wanted to see if diff data could improve future codegen requests, so I created a tool to generate a collection of `.diff` files for each pattern that included the commit message as a header in each file. I then used those diff files as part of a new instance of the prompt to test whether the model can improve its output when guided. ## # What did I learn? After analyzing hundreds of generated code snippets, the results are sobering. The model consistently demonstrates what I’d describe as superficial awareness without true understanding — it knows accessibility concepts exist but fundamentally misunderstands their purpose and proper implementation. Here are some of the patterns I’ve documented: **Form Label Disasters** : When asked to create a required text field, the model failed to include a visible label: <input type="text" id="orangeColor" name="orangeColor" required placeholder="What color is an orange?"> Sure, the `placeholder` attribute is there, and in a pinch it will be included in a field’s accessible name calculation, but sighted users will lose the label as soon as they start typing. **ARIA Attribute Confusion** : The model would routinely involce ARIA for no reason: <label for="color-question">What color is an orange? <span style="color: red;">*</span></label> <input type="text" id="color-question" name="color-question" required aria-required="true" aria-labelledby="color-question"> Here the `for` attribute already establishes the relationship between the label and input, so `aria-labelledby` is redundant. A bit of a nitpick, but the `aria-required="true"` is also unnecessary since the native `required` attribute already conveys that information to assistive technologies. `aria-required="true"` is only needed when creating custom form controls non-semantic markup. **Redundant ARIA** : Keeping on the ARIA redundancy, consider examples like this: <input type="radio" id="option1" aria-labelledby="label1" aria-label="Option 1"> <label for="option1" id="label1">Option 1</label> This redundancy raises the question _why‽_ **Required Field Misapplication** : For checkbox groups where users need to select “one or more,” the model often adds `required` to individual checkboxes: <fieldset> <legend>What fruits do you like?</legend> <div> <input type="checkbox" id="bananas" name="fruits" value="bananas" required> <label for="bananas">Bananas</label> </div> <div> <input type="checkbox" id="oranges" name="fruits" value="oranges" required> <label for="oranges">Oranges</label> </div> <div> <input type="checkbox" id="apples" name="fruits" value="apples" required> <label for="apples">Apples</label> </div> <div style="color: red; display: none;" id="validation-error">You must choose one or more fruits</div> </fieldset> This breaks the intended behavior—if any checkbox is marked required, it must be checked for form validation to pass. For a web component that addresses this limitation in HTML, see my post “Requirement Rules for Checkboxes.” **Grouped Field Confusion** : Not understanding when to use `fieldset` and `legend` (or at least using `role="group"` and `aria-labelledby`) on a field group: <div> <label>Select Theme:</label> <div> <input type="radio" id="light" name="theme" value="light"> <label for="light">Light</label> </div> <div> <input type="radio" id="dark" name="theme" value="dark"> <label for="dark">Dark</label> </div> <div> <input type="radio" id="high-contrast" name="theme" value="high-contrast"> <label for="high-contrast">High Contrast</label> </div> <p>You can change this later</p> </div> Ideally, this would be a `fieldset` with a `legend` and the descriptive text would appear right after the `legend` and be associated with the group using `aria-describedby`. **Color-Only Error Indication** : Generating error states that rely solely on color changes without text indicators or proper ARIA attributes to convey the error state to screen readers. **Unnecessary Role Additions** : Adding redundant roles like `role="radiogroup"` to properly structured fieldsets containing radio inputs, where the native semantics already provide the correct accessibility tree. **Missing Error State Management** : Failing to include `aria-invalid="true"` on fields with errors or properly associate error messages with their corresponding form controls. **Lack of Wayfinding Help** : Failing to include navigational labels and `aria-current="page"` in a breadcrumb nav. **Adding Unnecessary JavaScript** : Even though it was instructed to only generate JavaScript when absolutely necessary, the model would often inject JavaScript for simple tasks that could be handled with HTML and CSS alone. ## # How Does This Help? Here’s where things get interesting — and hopeful. When I retested using prompts that included accessibility hints, the model’s output improved dramatically. Not just slightly better, but often going from fundamentally broken to genuinely accessible. For example, when I added diff data related to fieldset use to a prompt about radio button groups, the model switched from generating meaningless `div` wrappers to proper semantic structures. This suggests the model can produce quality code if properly primed. It also indicates that the training data likely lacks sufficient examples of well-implemented accessible components. If the model had been trained on a richer dataset of accessible code, it might not need such explicit guidance to produce good results. ## # Where Do We Go From Here? These findings point to several concrete approaches for improving accessibility in AI-generated code: **Enhanced Training Data** : The models need exposure to more high-quality, accessible code examples. Current training data clearly overrepresents inaccessible implementations. We need comprehensive datasets of properly implemented accessible components across different frameworks and use cases. **Accessibility-Aware Fine-Tuning** : Post-training refinement specifically focused on accessibility compliance could help models prioritize inclusive patterns. This could involve training on accessibility-annotated code pairs — showing inaccessible implementations alongside their accessible counterparts, like the diffs do. **Prompt Engineering Guidelines** : Tool creators should integrate accessibility considerations into their default system prompts. Instead of just asking for “clean, semantic HTML,” prompts should provide detailed instructions to demonstrate accessibility best practices rather than pointing at often vague guidelines like WCAG." **Integrated Accessibility Validation** : IDE integrations should include real-time accessibility linting of AI-generated code, providing immediate feedback and suggestions for improvement. **Community-Contributed Training Data** : We should coordinate our efforts to produce an open source, high-quality accessible code dataset so that this data can be integrated into future models. * * * The data from this project provides a roadmap for where to focus these efforts. We’re not dealing with models that are fundamentally incapable of generating accessible code — we’re dealing with models that haven’t been properly trained to prioritize accessibility by default. ## # Want to Get Involved? If you want to conduct similar evaluations with your preferred models or specific use cases, I’ve created a template repository with the testing framework: CodeGen Model Eval and Refine Tools. It includes the Python testing harness, prompt templates, and analysis guidelines to get you started. The complete findings, methodology details, and code samples for my research are available on GitHub. I encourage you to dig into the data — it’s eye-opening and frustrating, yes, but ultimately actionable. There are other projects and research exploring this space as well. A few worth checking out: * AIMAC - The AI Model Accessibility Checker (AIMAC) Leaderboard measures how well LLMs generate accessible HTML pages using neutral prompts without specific accessibility guidance. Checks are performed with axe-core. * A11y LLM Evaluation Harness and Dataset - A more recent research project to evaluate how well various LLM models generate accessible HTML content. * * * We’re at a critical moment where the patterns established in AI-assisted development will shape the accessibility of the web for years to come. We can either let this technology amplify existing accessibility problems, or we can tackle the problems head-on and be part of the solution.
www.aaron-gustafson.com
October 16, 2025 at 8:59 PM
In a recent study, the VA learned a lot from users navigating acute distress — and why typical UX patterns fail.

https://medium.com/@codybolandphd/designing-for-distress-understanding-users-in-crisis-0e02466f1f5b
October 10, 2025 at 6:56 PM