<?xml version="1.0" encoding="utf-8"?>
  <rss version="2.0"
    xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:wfw="http://wellformedweb.org/CommentAPI/"
    xmlns:dc="http://purl.org/dc/elements/1.1/"
    xmlns:atom="http://www.w3.org/2005/Atom"
    xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
    xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
    xmlns:georss="http://www.georss.org/georss"
    xmlns:geo="http://www.w3.org/2003/01/geo/wgs84_pos#"
  >
    <channel>
      <title>Piccalilli - Articles</title>
      <link>https://piccalil.li/</link>
      <atom:link href="https://piccalil.li/articles.xml" rel="self" type="application/rss+xml" />
      <description>We are Piccalilli. A publication dedicated to providing high quality educational content to level up your front-end skills.</description>
      <language>en-GB</language>
      <copyright>Piccalilli - Articles 2026</copyright>
      <docs>https://www.rssboard.org/rss-specification</docs>
      <pubDate>Fri, 21 Aug 2026 07:11:40 GMT</pubDate>
      <lastBuildDate>Fri, 21 Aug 2026 07:11:40 GMT</lastBuildDate>

      
      <item>
        <title>A look at the geolocation HTML element and how it works</title>
        <link>https://piccalil.li/blog/a-look-at-the-geolocation-html-element-and-how-it-works/?ref=articles-rss-feed</link>
        <dc:creator><![CDATA[Daniel Schwarz]]></dc:creator>
        <pubDate>Thu, 20 Aug 2026 11:55:00 GMT</pubDate>
        <guid isPermaLink="true">https://piccalil.li/blog/a-look-at-the-geolocation-html-element-and-how-it-works/?ref=articles-rss-feed</guid>
        <description><![CDATA[<p>The <code>&lt;geolocation&gt;</code> HTML element does exactly what you might think it does. It’s a dedicated element that gets the user’s location, either once or continuously. The options are set using HTML attributes instead of JavaScript, but JavaScript is still needed. However, <code>&lt;geolocation&gt;</code> requires <em>fewer lines</em> of JavaScript and also offers superior error and permission handling. In fact, the <code>&lt;geolocation&gt;</code> element started off as an all-purpose <code>&lt;permission&gt;</code> element, but is now dedicated to handling geolocation.</p>
<p>Fewer lines of code is always a good thing, but it’s the permission prompt that’s key here. When using the aging Geolocation JavaScript API, denying permission at any point can lock the user out with no way to recover unless they change the browser or operating system permissions manually. This can be a painful experience, especially for users that aren’t tech-savvy. Plus, as developers, we have to get the Permissions API involved.</p>
<p>Whereas, the <code>&lt;geolocation&gt;</code> element offers:</p>
<ul>
<li>Better error handling, and where applicable, recovery handholding</li>
<li>User-controlled permission prompting, even if the user denied permission previously</li>
<li>Auto-location if the user granted permission previously</li>
<li>A styleable granted state via the <code>:granted</code> CSS pseudo-class</li>
</ul>
<p><code>&lt;geolocation&gt;</code> is better in every way except browser support (it requires Chrome 144+), so in this article, I’ll explain how to use it alongside the older current Geolocation JavaScript API so that users get the better experience, if the new element is available.</p>
<p></p>
<h2>Requesting the user’s location using <code>&lt;geolocation&gt;</code></h2>
<p>The <code>&lt;geolocation&gt;</code> element accepts several attributes that largely correspond to the options of the <code>getCurrentPosition()</code> and <code>watchPosition()</code> methods of the aging Geolocation API.</p>
<p>Firstly, the <code>accuracymode</code> attribute accepts two values — <code>approximate</code> (which is the default value) and <code>precise</code>.</p>
<p><code>accuracymode=approximate</code> is equivalent to the <code>enableHighAccuracy: false</code> option (again, the default) from the Geolocation API, whereas <code>accuracymode=precise</code> is equivalent to <code>enableHighAccuracy: true</code>, which provides a more accurate location if the device is able to get one.</p>
<p>The <code>autolocate</code> boolean attribute attempts to get the user’s location automatically, assuming that they’ve granted the website permission previously.</p>
<p>The <code>watch</code> boolean attribute is equivalent to calling the <code>watchPosition()</code> method instead of the <code>getCurrentPosition()</code> method. <code>getCurrentPosition()</code> fetches the user’s location once, whereas <code>watchPosition()</code> tracks their location over time. Like <code>accuracymode=precise</code> and <code>enableHighAccuracy: true</code>, this drains the battery faster.</p>
<p>The <code>onlocation</code> event handler attribute can be used to execute JavaScript whenever location data or error information is passed to the browser.</p>
<p>What the <code>&lt;geolocation&gt;</code> element doesn’t offer, though, is the ability to specify the <code>timeout</code> (how long the browser should wait for a response) or <code>maximumAge</code> (how old a cached location can be). Instead the browser handles these as it sees fit, which is actually a good thing, because choosing the right values on a case-by-case basis is a complexity that we just don’t need.</p>
<p>In practice, this is how you might use <code>&lt;geolocation&gt;</code>:</p>
<pre><code>&lt;!-- Precisely autolocate and follow the user --&gt;
&lt;geolocation accuracymode="precise" autolocate watch&gt;
  &lt;!-- Render this when &lt;geolocation&gt; is unsupported --&gt;
  &lt;button id="fallbackButton"&gt;Use precise location&lt;/button&gt;
&lt;/geolocation&gt;
</code></pre>
<p>That is, of course, not including the JavaScript side of <code>&lt;geolocation&gt;</code> nor the Geolocation (JavaScript) API fallback. If you just want the full code then you’re looking for the <code>getCurrentPosition()</code> version and <code>watchPosition()</code> version. Note that geolocation doesn’t work in insecure contexts, so while the logic is sound, the CodePen demos won’t actually work. They’re raw-logic templates anyway, not fully working demonstrations.</p>
<p>Anyway, let’s get into the JavaScript of it all, starting with the JavaScript that sits on the other side of that <code>&lt;geolocation&gt;</code> markup.</p>
<h2>Handling the <code>&lt;geolocation&gt;</code> data with JavaScript</h2>
<p>First, we want to make sure that <code>&lt;geolocation&gt;</code> is supported with <code>if ("HTMLGeolocationElement" in window)</code>. If it is, then we select it with <code>const geoElement = document.querySelector("geolocation")</code>.</p>
<p>Unfortunately, we then have to dive into the most complex part of <code>&lt;geolocation&gt;</code> — it’s validity — where the <code>isValid</code> property returns <code>true</code> or <code>false</code> and the <code>invalidReason</code> property returns <code>""</code> (an empty string) or an enumerated value stating the reason.</p>
<p>These reasons mostly come down to developer oversight, so users <em>shouldn’t</em> encounter these ‘blockers’, but if they do, the <code>&lt;geolocation&gt;</code> button will be disabled. Here are the scenarios in which that can happen (note that the blockers are ordered by severity, and that <code>invalidReason</code> only returns the most severe one):</p>
<ul>
<li><code>illegal_subframe</code>: the <code>&lt;geolocation&gt;</code> element is nested within a <code>&lt;fencedframe&gt;</code> or insecure <code>&lt;iframe&gt;</code> (the latter of which is why the CodePen demos don’t work)</li>
<li><code>unsuccessful_registration</code>: the page has more than three <code>&lt;geolocation&gt;</code> elements</li>
<li><code>recently_attached</code>: the <code>&lt;geolocation&gt;</code> element has only recently been attached to the DOM (this blocker expires fairly quickly)</li>
<li><code>intersection_changed</code>: the <code>&lt;geolocation&gt;</code> element is moving</li>
<li><code>intersection_out_of_viewport_or_clipped</code> : the <code>&lt;geolocation&gt;</code> element isn’t within the viewport fully</li>
<li><code>intersection_occluded_or_distorted</code>: something is obscuring the <code>&lt;geolocation&gt;</code> element</li>
<li><code>style_invalid</code>: the <code>&lt;geolocation&gt;</code> element is styled in a way that isn’t allowed (more on this later)</li>
</ul>
<p>As you can see, these blockers are permanently avoidable as long as we catch and fix them. Let’s have a proper look at how <code>invalidReason</code> reports these blockers, though.</p>
<p>As the JavaScript comment below describes, <code>isValid</code> always returns <code>false</code> at first, while <code>invalidReason</code> returns <code>recently_attached</code>. This basically disables the <code>&lt;geolocation&gt;</code> button for a fraction of a second to prevent clickjacking. That is, unless a blocker of higher severity applies. We don’t actually need the line below, it’s there just to show you how <code>&lt;geolocation&gt;</code> will never be valid when the page loads.</p>
<pre><code>/* At first, isValid === false and invalidReason === "recently_attached"
unless isValid === false and invalidReason === "a reason of higher severity" */
console.warn(`isValid: ${geoElement.isValid}, invalidReason: ${geoElement.invalidReason}`);
</code></pre>
<p>If <code>&lt;geolocation&gt;</code> then becomes invalid for a more permanent reason, <code>isValid</code> will obviously remain <code>false</code>, so we can’t use the <code>validationstatuschange</code> event listener here. However, we can wrap the line in <code>setTimeout()</code> (as below). This will either log a new blocker into the console, or log that <code>isValid</code> is <code>true</code> (in which case <code>invalidReason</code> will be an empty string).</p>
<pre><code>/* After ~300ms, when &lt;geolocation&gt; is no longer ‘recently attached’ (to the DOM)
either isValid === true and invalidReason === "" (an empty string)
or isValid === false and invalidReason === "the reason with the highest severity" */
setTimeout(() =&gt; {
  console.log(`isValid: ${geoElement.isValid}, invalidReason: ${geoElement.invalidReason}`);
}, 300);
</code></pre>
<p>If the validation status changes later, <em>then</em> we can use the <code>validationstatuschange</code> event listener (again, as below). Note that if there are no persistent blockers, this will fire almost immediately as <code>invalidReason</code> switches from <code>recently_attached</code> to an empty string.</p>
<pre><code>/* If the validity of &lt;geolocation&gt; changes */
geoElement.addEventListener("validationstatuschange", () =&gt; {
  if (geoElement.isValid) {
    /* &lt;geolocation&gt; is valid (if there aren’t any blockers,
    it will become valid after it’s no longer ‘recently attached’ */
  } else {
    console.error(`&lt;geolocation&gt; invalid: ${geoElement.invalidReason}`);
  }
});
</code></pre>
<p>Now that you know how to debug the validation status and permanently fix any persistent blockers, let’s talk about the <em>permission</em> status.</p>
<p>We’re given a few properties and events to work with:</p>
<ul>
<li><code>initialPermissionStatus</code>: a property that returns <code>denied</code>, <code>granted</code>, or <code>prompt</code> (i.e., neither) based on the permission status when the page first loaded</li>
<li><code>permissionStatus</code>: the <em>current</em> permission status</li>
<li><code>promptaction</code>: an event that fires when the user denies or grants permission from the <code>&lt;geolocation&gt;</code> permission prompt dialog</li>
<li><code>promptdismiss</code>: fires when the user dismisses the dialog, in which case the <code>permissionStatus</code> remains unchanged</li>
</ul>
<pre><code>/* Determine the initial permission status */
if (geoElement.initialPermissionStatus === "denied") {
  /* The user previously denied permission */
} else if (geoElement.initialPermissionStatus === "granted") {
  /* The user previously granted permission */
} else if (geoElement.initialPermissionStatus === "prompt") {
  /* The user hasn’t made a choice */
}

/* If the user denies or grants permission */
geoElement.addEventListener("promptaction", () =&gt; {
  if (geoElement.permissionStatus === "denied") {
    /* The user denied permission */
  } else if (geoElement.permissionStatus === "granted") {
    /* The user granted permission */
  }
});

/* If the user dismisses the prompt */
geoElement.addEventListener("promptdismiss", () =&gt; {
  if (geoElement.permissionStatus === "denied") {
    /* The permission state remained denied */
  } else if (geoElement.permissionStatus === "granted") {
    /* The permission state remained granted */
  } else if (geoElement.permissionStatus === "prompt") {
    /* The permission state remained prompt */
  }
});
</code></pre>
<p>That being said, I honestly don’t know what we’d need any of that for. If the user previously denied permission, for example, the <code>&lt;geolocation&gt;</code> permission prompt dialog would enable the user to recover from that automatically:</p>
<p><img src="https://piccalil.b-cdn.net/images/blog/geolocation-1.png" alt="Two confirmation boxes. One reads &quot;You previously didn't allow location for this site&quot; and the other reads &quot;To use your location on this site, give Chrome access&quot;" /></p>
<p>This is in contrast to the older Geolocation API, which is unlikely to help users recover. In this case we need to read the permission status, manage the state of the component accordingly, and provide recovery instructions so that users can grant access manually, but <code>&lt;geolocation&gt;</code> takes care of all of that.</p>
<p>What you <em>will</em> need is the new <code>location</code> event, which fires whenever the browser passes location data (<code>geoElement.position</code> in this case) or error information (<code>geoElement.error</code>) to us.</p>
<p>Then, in the event listener callback, assuming that <code>geoElement.position</code> is truthy, we can access <code>position.coords</code> and <code>position.timestamp</code>, synthesizing the location data like this:</p>
<pre><code>/* If the browser passes location data or error information */
geoElement.addEventListener("location", () =&gt; {
  /* If location data */
  if (geoElement.position) {
    /* Synthesize the data */
    const {
      latitude,
      longitude,
      altitude,
      accuracy,
      altitudeAccuracy,
      heading,
      speed
    } = geoElement.position.coords;

    const timestamp = geoElement.position.timestamp;
  } else if (geoElement.error) {
    /* If error information */
  }
});
</code></pre>
<p>However, if <code>geoElement.error</code> is truthy, we can access <code>error.message</code> (that’s for us to log into the console) and <code>error.code</code>, which is much more suitable for error handling.</p>
<p>In short, there are three possible error codes, each with an associated constant so that we don’t need to remember what each error code represents. So <code>1</code> represents <code>PERMISSION_DENIED</code>, <code>2</code> represents <code>POSITION_UNAVAILABLE</code>, and finally, <code>3</code> represents <code>TIMEOUT</code>, and then we just evaluate them like this:</p>
<pre><code>/* If the browser passes location data or error information */
geoElement.addEventListener("location", () =&gt; {
  if (geoElement.position) {
    /* If location data */
  } else if (geoElement.error) {
    /* If error information */
    console.error(`&lt;geolocation&gt; error: ${geoElement.error.message}`);

    if (geoElement.error.code === geoElement.error.PERMISSION_DENIED) {
      /* No HTTPS or server misconfiguration */
    } else if (geoElement.error.code === geoElement.error.POSITION_UNAVAILABLE) {
      /* No location source (GPS satellite or nearby Wi-Fi network/cellular tower) */
    } else if (geoElement.error.code === geoElement.error.TIMEOUT) {
      /* Location source detection or hardware took too long */
    }
  }
});
</code></pre>
<p><code>PERMISSION_DENIED</code> means that the website isn’t being served over HTTPS (the browser denied permission), or that there’s some kind of server misconfiguration (the server denied permission), but those two errors are permanently fixable and shouldn’t occur in production.</p>
<p>The only way to deny permission (as far as I’m aware) is to block location access from the browser settings, then click on the <code>&lt;geolocation&gt;</code> button, then choose to continue denying. In my opinion, that’s not likely to happen and doesn’t warrant an error message anyway. There isn’t a denial mechanism for OS-level blocks so as not to make the impression that the browser can enforce one. In short, I don’t think we need to do anything for <code>PERMISSION_DENIED</code>.</p>
<p>And to clarify, because <code>&lt;geolocation&gt;</code> is user-invoked, <code>&lt;geolocation&gt;</code> itself never sets the permission status to denied (again, as far as I know).</p>
<p><code>POSITION_UNAVAILABLE</code> means that the device can’t detect a location source (nearby Wi-Fi networks and cellular towers as well as GPS satellites) to determine the location. Using a VPN or visiting the website via an in-app browser could cause this error too, so this is the trickiest error to convey to users.</p>
<p><code>TIMEOUT</code> means that the location source detection or hardware took too long, and that users should try again.</p>
<p>How you communicate errors to users is totally up to you.</p>
<p></p>
<h2>Falling back to the Geolocation JavaScript API</h2>
<p>Ready for round two? Now we’re going to do the same thing but with the aging Geolocation API, which is supported in every browser, but kind of a headache.</p>
<p>This is where we’re at currently:</p>
<pre><code>/* If &lt;geolocation&gt; is supported */
if ("HTMLGeolocationElement" in window) {
  /* What we covered in the previous section */
} else {
  /* What we’re focusing on now (the fallback) */
}
</code></pre>
<p>Within that <code>else</code> block, which runs when <code>&lt;geolocation&gt;</code> isn’t supported, we start off by selecting the fallback button (<code>const fallbackButton = document.querySelector("#fallbackButton")</code>). If you recall, this is nested within <code>&lt;geolocation&gt;</code> so that it’s ignored when <code>&lt;geolocation&gt;</code> <em>is</em> supported.</p>
<p>After that we create a function (<code>updateState()</code>) that manages the component state and provides recovery instructions. As arguments we supply the <code>state</code> as a string (<code>‌granted</code>, <code>‌prompt</code>, or <code>‌denied</code>, corresponding with the <code>permissionStatus</code>), and optionally, <code>statusMessage</code>, which’ll be used to convey status messages to the user. I don’t want to make any assumptions about your component, so how you convey the <code>statusMessage</code> and expand upon <code>updateState()</code> is up to you.</p>
<p>If <code>state === "denied"</code>, we disable the button with <code>fallbackButton.disabled = true</code> and provide some kind of recovery instruction of which should be passed as the second argument of the function. The reason why we disable the button is that <em>this</em> Geolocation API isn’t user-invoked, so to protect the user from spam requests, the browser can suppress requests and send the API straight to jail without passing go, triggering <code>PERMISSION_DENIED</code>. Additionally, if we make the API user-invoked (as we have), users can spam the button themselves and basically shadowblock themselves, but disabling the button fixes that.</p>
<p>If <code>state === "granted"</code> or <code>state === "prompt"</code>, we can enable the button (<code>fallbackButton.disabled = false</code>).</p>
<p>Now is a good time to mention that the earlier JavaScript code for the <code>&lt;geolocation&gt;</code> element works regardless of whether the element has the <code>watch</code> attribute or not. However, when using this older Geolocation API, there are two additional things that we need to take care of when trying to keep track of the user’s location continuously. The first thing is the watcher ID, which is returned by the <code>watchPosition()</code> method. Knowing this ID enables us to clear the watcher before registering a new one, which is a must-do for performance reasons.</p>
<p>So we <code>let watcherID = null</code> for now, and then we create the function that attempts to get the location (<code>getLocation()</code>), and the first thing that we do within that function, assuming that <code>watcherID !== null</code> (meaning that it’s been set before), is clear the watcher using <code>navigator.geolocation.clearWatch(watcherID)</code> and make <code>watcherID = null</code> again:</p>
<pre><code>/* A function for getting the location */
const getLocation = () =&gt; {
  /* If a watcher has already been registered */
  if (watcherID !== null) {
    /* Unregister it */
    navigator.geolocation.clearWatch(watcherID);
    watcherID = null;
  }
}
</code></pre>
<p>Then we disable the button using <code>fallbackButton.disabled = true</code> to, again, prevent spam clicks. If you want to bake some kind of loading indicator in, feel free to, but <code>&lt;geolocation&gt;</code> doesn’t.</p>
<p>After that we call <code>navigator.geolocation.watchPosition()</code>, setting <code>watcherID</code> to the returned watcher ID. This method has three parameters — success, error, and options.</p>
<pre><code>watcherID = navigator.geolocation.watchPosition(
  (position) =&gt; {
    /* Success */
  },
  (error) =&gt; {
    /* Error */
  },
  {
    /* Options */
  }
);
</code></pre>
<p>For the success callback function we synthesize the location data similarly to last time, then call <code>updateState("granted")</code>.</p>
<p>For the error callback function (optional but highly recommended) we clear the watcher and, again, make <code>watcherID = null</code>, but otherwise run the same error handling logic that <code>&lt;geolocation&gt;</code> runs. However, there are more circumstances in which the errors can occur.</p>
<p>For example, because <code>watchPosition()</code> and <code>getCurrentPosition()</code> aren’t necessarily user-invoked, there are more scenarios in which users are able to deny access, triggering the <code>PERMISSION_DENIED</code> error. Accordingly, we should call <code>updateState("denied", "Permission denied (try this or that)")</code>, offering a useful status message and clear recovery instructions.</p>
<p>Similarly, <code>POSITION_UNAVAILABLE</code> can also be triggered by an OS-level block, since not all web browsers catch this during the permission prompt dialog. <code>updateState("prompt", "Position unavailable (try this or that)")</code> is what we’re looking for this time.</p>
<p>Finally, another scenario that doesn’t occur with <code>&lt;geolocation&gt;</code> but does with <em>this</em> Geolocation API, is that if the browser prompts the user to grant permission at the OS-level, but then the user cancels their request, that can trigger the <code>TIMEOUT</code> error. Either way, call <code>updateState("prompt", "Request timed out (try this or that)”)</code>, once again tweaking it to your liking.</p>
<p>As you can see, the error reporting isn’t the best. Sometimes the error isn’t identified correctly, and even when it is, the error can occur for various reasons, which makes it difficult for us to convey a useful status message and clear recovery instructions. <code>&lt;geolocation&gt;</code> handles this better — the errors are identified correctly, and the nature of <code>&lt;geolocation&gt;</code> ensures that certain errors never occur to begin with. But why the error categories? Why not tell us exactly what went wrong?</p>
<p>Well, the reason is to make fingerprinting more difficult, and while we can totally put in the extra work to pinpoint the exact problem, simply stating what happened and what the user should do next is perfectly fine. Of course, <code>error.message</code> tells us more than <code>error.code</code> does, but the messages can be a bit vague and differ in every web browser, so we can’t read them or even output them reliably.</p>
<p>Anyway, the optional third parameter expects an object where we can set:</p>
<ul>
<li><code>enableHighAccuracy</code>: <code>true</code> or <code>false</code></li>
<li><code>timeout</code>: <code>20000</code> (20 seconds) is reasonable if <code>enableHighAccuracy: true</code>, <code>3000</code> - <code>5000</code> otherwise</li>
<li><code>maximumAge</code>: <code>0</code> for turn-by-turn navigation, <code>5000</code> - <code>10000</code> (5-10 seconds) for live tracking, <code>300000</code> - <code>600000</code> (5-10 minutes) for frequent updates (e.g., weather)</li>
</ul>
<p>I’d rather that the web browser choose the <code>timeout</code> and <code>maximumAge</code> for us, especially considering the impact that they have on the <code>TIMEOUT</code> error and device battery, but of course, that’s exactly what <code>&lt;geolocation&gt;</code> does.</p>
<pre><code>/* Attempt to get the location and store the returned ID */
watcherID = navigator.geolocation.watchPosition(
  /* If location data is passed (like before) */
  (position) =&gt; {
    /* Synthesize the data (again, like before) */
    const {
      latitude,
      longitude,
      altitude,
      accuracy,
      altitudeAccuracy,
      heading,
      speed
    } = position.coords;

    const timestamp = position.timestamp;

    /* And update the state */
    updateState("granted");
  },

  /* If error information is passed (yep, like before) */
  (error) =&gt; {
    console.error(`Geolocation error: ${error.message}`);

    /* Again, unregister the watcher (if necessary) */
    if (watcherID !== null) {
      navigator.geolocation.clearWatch(watcherID);
      watcherID = null;
    }

    if (error.code === error.PERMISSION_DENIED) {
      /* No HTTPS, server misconfiguration, or the user denied access */
      updateState("denied", "Permission denied (try this or that)");
    } else if (error.code === error.POSITION_UNAVAILABLE) {
      /* No location source (GPS satellite or nearby Wi-Fi network/cellular tower) or OS-level access */
      updateState("prompt", "Position unavailable (try this or that)");
    } else if (error.code === error.TIMEOUT) {
      /* Location source detection or hardware took too long, or the user canceled their request */
      updateState("prompt", "Request timed out (try this or that)");
    }
  },
  {
    enableHighAccuracy: true,
    timeout: 20000 /* 20 seconds because enableHighAccuracy: true */,
    maximumAge: 0 /* 0 seconds because we’re demanding high accuracy */
  }
);
</code></pre>
<p>After that we need to query the permission status using the Permissions API (<code>navigator.permissions.query({ name: "geolocation" }).then((permissionStatus) =&gt; { /* ... */ })</code>), and then execute all of the aforementioned logic based on that.</p>
<p>If <code>permissionStatus.state === "granted"</code>, we call <code>getLocation()</code>, which is essentially what the <code>autolocate</code> attribute does. If you don’t want autolocation, simply delete this part. If it’s anything else, we basically determine the initial state by calling <code>updateState(permissionStatus.state)</code>.</p>
<pre><code>/* Query the permission status */
navigator.permissions.query({ name: "geolocation" }).then((permissionStatus) =&gt; {
  /* Equivalent to the autolocate attribute */
  if (permissionStatus.state === "granted") {
    getLocation();
  } else {
    /* React accordingly */
    updateState(permissionStatus.state);
  }
});
</code></pre>
<p>Finally, make the button respond to clicks:</p>
<pre><code>/* If the user clicks the fallback button */
fallbackButton.addEventListener("click", () =&gt; {
  getLocation();
});
</code></pre>
<p>If we only want to get the user’s location once (rather than watch it continuously), these are the modifications that we need to make:</p>
<ul>
<li>Remove everything related to the <code>watcherID</code></li>
<li>Swap <code>watchPosition()</code> for <code>getCurrentPosition()</code></li>
<li>Provide different settings for the options parameter</li>
</ul>
<p>Note that the <code>change</code> event, which could help us update the state whenever the user changes their browser-level permission status, doesn’t fire in Safari. Besides, the standard browser behavior is to ask users to refresh the page, so let’s stick with that.</p>
<p>One more thing — I wanted the button to say “Refresh precise location” and “Getting precise location…” at certain points, but <code>&lt;geolocation&gt;</code> doesn’t do this, so I didn’t either. That’s up to you, though.</p>
<p></p>
<h2>Styling the <code>&lt;geolocation&gt;</code> element</h2>
<p><code>&lt;geolocation&gt;</code> has a <code>:granted</code> pseudo-class. If we throw <code>:not(:granted)</code> into the mix, we can target the prompt/denied states too:</p>
<pre><code>geolocation {
  &amp;:granted {
    /* Permission granted */
  }

  &amp;:not(:granted) {
    /* Permission not granted */
  }
}
</code></pre>
<p>With the Geolocation API, we can toggle classes as needed to achieve the same effect, but honestly, I’ve never felt compeled to style such a button in this way. Personally, I’d like to be able to change the icon and text (maybe this is something that you can bake into the <code>updateState()</code> function), but this is one of the things that the <code>&lt;geolocation&gt;</code> element outright forbids.</p>
<p><img src="https://piccalil.b-cdn.net/images/blog/geolocation-2.png" alt="A button element labelled &quot;use precise location&quot;" /></p>
<p>Before we dive into all <em>that</em>, here’s a rundown of <code>&lt;geolocation&gt;</code> rules stated by Google’s explainer and Mozilla’s explainer:</p>
<ul>
<li>There must be sufficient color contrast</li>
<li>The alpha channel must resolve to <code>1</code> (so no transparency)</li>
<li>The minimum and maximum width, height, and font size must be respected</li>
<li>Negative margins and outline offsets aren’t allowed either</li>
<li>Distortion effects — including linear gradients — are banned</li>
</ul>
<p>It’s also worth noting that some of these rules function like guardrails (for example, you physically can’t style the height above 50px), whereas others cause <code>invalidReason</code> to return <code>style_invalid</code> and the <code>&lt;geolocation&gt;</code> button to be disabled.</p>
<p>Some of the rules seem to have changed (or are currently bugged). <code>&lt;geolocation&gt;</code> doesn’t respect <code>prefers-color-scheme</code> either, but it’s a developing feature, so I’m not going to comment on all of that too much. I’ll just state what I do and don’t like:</p>
<ul>
<li>I don’t like that the button becomes disabled because of a style (guardrails are better, but I don’t really like either)</li>
<li>I don’t like the forced icon and text, and I’m also not too happy about not being able to use gradients or <code>corner-shape</code></li>
<li>I can make peace with everything else because I’d normally design within those guardrails anyway</li>
<li>The text being localized into different languages is very cool, but it’s a first for web standards and I don’t currently enjoy that it’s a thing for only these permission buttons</li>
</ul>
<p>While I obviously support preventing web authors from tricking users into giving away their location, I think it’d be better if <code>&lt;geolocation&gt;</code> were fully styleable and the permission prompt dialog be ultra clear about what the user is about to commit to.</p>
<p>Even though I can style the background, color, border, border radius, and box shadow how I’d want them (as in the image below, which is most of what I’d want), this feels like a step back towards unstyleable controls. Having said that, this approach means well (<em>extremely</em> well), so I can’t wait to see where this goes (and frankly, to be able to throw the aging Geolocation API in the bin).</p>
<p><img src="https://piccalil.b-cdn.net/images/blog/geolocation-3.png" alt="A purple button element with white text, labelled &quot;use precise location&quot;" /></p>
<h2>What now? What else?</h2>
<p>When all web browsers support <code>&lt;geolocation&gt;</code>, the user experience and developer experience will improve dramatically.</p>
<p>But that’s not all!</p>
<p>What was once the <code>&lt;permission&gt;</code> element is now the <code>&lt;geolocation&gt;</code> element, <code>&lt;install&gt;</code> element (which facilitates the installation of Progressive Web Apps), <code>&lt;usermedia&gt;</code> element (which facilitates access to the user’s camera and/or microphone), and <code>&lt;camera&gt;</code> and <code>&lt;microphone&gt;</code> elements (which facilitates access to them individually).</p>
<p>All of these are being trialed in Chrome as part of a larger initiative to improve the process and experience of requesting permission, which is bloody great, in my opinion.</p>
        
        ]]></description>
        
      </item>
    
      <item>
        <title>Working with ::highlight() using progressive enhancement </title>
        <link>https://piccalil.li/blog/working-with-highlight-using-progressive-enhancement/?ref=articles-rss-feed</link>
        <dc:creator><![CDATA[Sunkanmi Fafowora]]></dc:creator>
        <pubDate>Thu, 06 Aug 2026 11:55:00 GMT</pubDate>
        <guid isPermaLink="true">https://piccalil.li/blog/working-with-highlight-using-progressive-enhancement/?ref=articles-rss-feed</guid>
        <description><![CDATA[<p><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/Pseudo-elements#highlight_pseudo-elements">Highlighting in CSS</a> has been beneficial for applying a highlight on specific text or text fragments during user selection, emphasizing a piece of information on a website, or visually emphasizing a text for the sake of branding. Particularly, this is pretty helpful when users want to scan your document from top to bottom because the <a href="https://www.nngroup.com/articles/concise-scannable-and-objective-how-to-write-for-the-web/">majority of people don’t read your document initially; they scan</a>.</p>
<p>On the web, text highlights are a good way to lay emphasis on text fragments through good ol’ CSS. From <code>::selection</code> for styling selected text to <code>::target-text</code> which styles highlighted text from Google searches, and in my opinion, CSS’ most powerful pseudo-element for highlighting: <code>::highlight()</code> which applies a custom highlight to a text fragment.</p>
<p>In this article, we will look into how the <code>::highlight()</code> pseudo-element works, the API behind this pseudo-element, and explore a fallback feature for this technology because it relies <strong>heavily on JavaScript (JS).</strong></p>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/XJjwNap">::highlight() demo: pure text-shadows</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p></p>
<h1>The CSS Custom Highlight API</h1>
<p>Typically, a highlight or highlighted text is what you’d see during web searches or when you what to select a text fragment to copy, or even when you make a mistake in a word processor (the squiggly red underlines)<em>.</em> In CSS, you can achieve these through the highlight pseudo-elements like<code>::search-text</code>, <code>::selection</code>, <code>::spelling-error</code> , and <code>::grammar-error</code> . But, what about plain highlights like the demo above? That’s where the CSS Custom Highlight API comes in.</p>
<p>The <a href="https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API">CSS Custom Highlight API</a> is an API for text highlighting on a range of text using JavaScript and CSS. It extends the pseudo-elements for highlighting (<code>::search-text</code> , <code>::selection</code>, <code>::spelling-error</code> ) and lets you customize text fragments with <code>::highlight()</code> and JavaScript. <code>::highlight()</code> is what will be our focus for this article, and how we can programmatically highlight text and its fragments using JavaScript and CSS.</p>
<h1>How ::highlight() works</h1>
<p>To create highlighted text like in the initial example, you need to know 4 total steps, which include:</p>
<ol>
<li>Creating the highlight buckets by creating instances of the <code>Highlight()</code> class</li>
<li>Register each highlight instance into <code>CSS.highlights</code></li>
<li>Create <code>Range()</code> objects each with different points on the text for highlighting and add <code>Range()</code> objects to its highlight instances</li>
<li>Style with <code>::highlight()</code> pseudo-element</li>
</ol>
<p>Before we move on with an example, let me explain something because this is where it might get tricky. What we do around here is <strong>build with <a href="https://piccalil.li/blog/its-about-time-i-tried-to-explain-what-progressive-enhancement-actually-is/">progressive enhancement</a> in mind first</strong>. So the first question you should ask before anything is “what happens <a href="https://piccalil.li/blog/a-handful-of-reasons-javascript-wont-be-available/"><em>when</em> JS fails</a>?”. Well, I’m glad you asked.</p>
<p>Let’s say, for our example, we want to create two highlight objects and use that to style a simple poetic text that reads “<em>fire and ice live inside every word.</em>” Because we care about our users, we set each text for highlight with the <code>&lt;mark&gt;</code> HTML tag setting the <code>class</code> attribute to either <code>gold</code> or <code>ice</code> , depending on the highlighting style we wish the text to have. This acts as a fallback highlight style in case our JavaScript fails or the browser doesn’t support custom highlighting.</p>
<p>We also apply an <code>id</code> to them in case JS is available too for our CSS Custom Highlight API. With all that in mind, our HTML would look like this:</p>
<pre><code>&lt;main&gt;
  &lt;h1&gt;Two Highlights Demo&lt;/h1&gt;
  &lt;p id="line"&gt;
    &lt;mark id="fire" class="gold"&gt;fire&lt;/mark&gt; and
    &lt;mark id="ice" class="ice"&gt;ice&lt;/mark&gt; live inside 
    &lt;mark id="every" class="ice"&gt;every&lt;/mark&gt; 
    &lt;mark id="word" class="gold"&gt;word&lt;/mark&gt;
  &lt;/p&gt;
&lt;/main&gt;
</code></pre>
<p>Then, we proceed to query each tagged word in our JS applying the <code>firstChild</code> property to each of them of get the element’s first child node which we will use later:</p>
<pre><code>const fireNode = document.querySelector("#fire").firstChild;
const everyNode = document.querySelector("#every").firstChild;
const iceNode = document.querySelector("#ice").firstChild;
const wordNode = document.querySelector("#word").firstChild;
</code></pre>
<p>Finally, we can proceed with the steps on creating a custom highlight in CSS.</p>
<p></p>
<h2>Create instances of <code>Highlight()</code> class</h2>
<p>In order to create a custom highlight, the first step is to create an instance of the <code>Highlight()</code> class which will house the highlight styling we want a text or text fragment to have. For our demo, we’ll be creating two highlight objects named using the <code>Highlight()</code> class. One to give a golden color representing fire and the other to give a blue color representing ice:</p>
<pre><code>const goldHL = new Highlight();
const iceHL = new Highlight();
</code></pre>
<h2>Register each highlight instance in CSS.highlights</h2>
<p>Next, we register the created highlight instances in the <code>HighlightRegistry</code> via <code>CSS.highlights</code> <code>set()</code> method. We map a valid CSS identifier to the instance for CSS styling later.</p>
<pre><code>CSS.highlights.set("hl-gold", goldHL);
CSS.highlights.set("hl-ice", iceHL);
</code></pre>
<h2>Create Range() objects each with different points on the text for highlighting</h2>
<p>In this step, we will be creating a <code>Range()</code> object for each text fragment we queried earlier for highlighting. We will then apply the highlight we want on each selected text. For the first word “fire”, we create a <code>Range()</code> object called <code>r1</code> , and we set the start node to the first letter “f” using <code>setStart</code> on <code>r1</code> . <code>setStart()</code> accepts two values. It accepts the node we’re targeting (in our case for “fire”, its <code>fireNode</code> ) and the index of the text on the node.</p>
<p>Now, because we want to target the whole text “fire”, we have to also set where the range will stop. And this will be set using <code>setEnd()</code> . <code>setEnd()</code> accepts two values like <code>setStart()</code> on the range object (<code>r1</code>). It accepts the node we’re targeting (<code>fireNode</code> ) and the index of the end text “e” (as in the “e” in “fire”) using <code>fireNode.textContent.length</code> - 1 which gives us the last index of the text.</p>
<p>Finally, we add the range object into the set instance.</p>
<pre><code>const r1 = new Range();
r1.setStart(fireNode, 0);
r1.setEnd(fireNode, fireNode.textContent.length - 1);
goldHL.add(r1);
</code></pre>
<p>This step is repeated for <code>everyNode</code>, <code>iceNode</code>, and <code>wordNode</code>. Typically, you’d want to use a loop for this, but because this is really small, writing it in a specific manner will suffice, especially to help you understand how this works too.</p>
<pre><code>const r2 = new Range();
r2.setStart(wordNode, 0);
r2.setEnd(wordNode, wordNode.textContent.length - 1);
goldHL.add(r2);

const r3 = new Range();
r3.setStart(iceNode, 0);
r3.setEnd(iceNode, iceNode.textContent.length - 1);
iceHL.add(r3);

const r4 = new Range();
r4.setStart(everyNode, 0);
r4.setEnd(everyNode, everyNode.textContent.length - 1);
iceHL.add(r4);
</code></pre>
<h2>Style with ::highlight() pseudo-element</h2>
<p>Remember how we said we should <strong>think progressive enhancement first?</strong> Well, in order to achieve that for this demo in particular, we need to style the <code>&lt;mark&gt;</code>ed highlighted text first, then, we style the <code>::highlight()</code> pseudo-element. For that to work, we styled text marked with the <code>gold</code> class to be golden in <code>oklch()</code> with a glowy text shadow of similar color. We style text <code>&lt;mark&gt;</code> ed with the <code>ice</code> class to be blueish in <code>oklch()</code> with a glowy text shadow of similar color:</p>
<pre><code>mark {
  background: none;
}

mark.gold {
  color: oklch(88% 0.16 75);
  text-shadow: 0 0 40px oklch(65% 0.22 75 / 0.4);
}

mark.ice {
  color: oklch(82% 0.1 215);
  text-shadow: 0 0 40px oklch(60% 0.18 215 / 0.4);
}
</code></pre>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/azBoBBo">::highlight() demo: two highlights without `::highlight()`</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p>Viola! (or how do they say it?) It looks amazing! 🤩</p>
<p>Even without the styled <code>::highlight()</code> , it works out pretty well. But, that’s not our only aim though. We still need to add the styling for both our highlight objects.</p>
<pre><code>::highlight(hl-gold) {
  color: oklch(88% 0.16 75);
  text-shadow: 0 0 40px oklch(65% 0.22 75 / 0.4);
}

::highlight(hl-ice) {
  color: oklch(82% 0.1 215);
  text-shadow: 0 0 40px oklch(60% 0.18 215 / 0.4);
}
</code></pre>
<p>Done! It’s pretty much the same styling we did for our <code>&lt;mark&gt;</code> tag classes and in case JS fails or the browser doesn’t support it, we wrap the <code>::higlight()</code> pseudo-elements in a <code>@supports</code> container:</p>
<pre><code>@supports selector(::highlight(h1-gold)) {
  ::highlight(hl-gold) {
    color: oklch(88% 0.16 75);
    text-shadow: 0 0 40px oklch(65% 0.22 75 / 0.4);
  }

  ::highlight(hl-ice) {
    color: oklch(82% 0.1 215);
    text-shadow: 0 0 40px oklch(60% 0.18 215 / 0.4);
  }
}
</code></pre>
<p></p><p>See the Pen <a href="https://codepen.io/piccalilli/pen/qEqWZYN">::highlight() demo: two highlights with fallback</a> by Andy Bell (<a href="https://codepen.io/piccalilli/">@piccalilli</a>) on <a href="https://codepen.io">CodePen</a>.</p><p></p>
<p></p>
<h2>Will you be using ::highlight()?</h2>
<p>I know for sure I will be using this feature in my work. This article explained highlighting in CSS, the CSS Custom Highlight API, and how <code>::higlight()</code> works in CSS with a fallback provision, for when JavaScript isn’t available or the browser doesn’t support it.</p>
<p>If you’re looking to read up more about the <a href="https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API">CSS Custom Highlight API</a>, MDN has a good guide on the topic for you.</p>
        
        ]]></description>
        
      </item>
    
      <item>
        <title>Use cases for aria-expanded</title>
        <link>https://piccalil.li/blog/use-cases-for-aria-expanded/?ref=articles-rss-feed</link>
        <dc:creator><![CDATA[Steve Frenzel]]></dc:creator>
        <pubDate>Thu, 16 Jul 2026 11:55:00 GMT</pubDate>
        <guid isPermaLink="true">https://piccalil.li/blog/use-cases-for-aria-expanded/?ref=articles-rss-feed</guid>
        <description><![CDATA[<p>Communication can be difficult. Not only when we humans try to communicate with one another, but also in web development.</p>
<p>While it is easy for sighted people with full mental and motor abilities to click a button with a mouse to display more information, users of Assistive Technology (AT) may have a completely different experience with the same action.</p>
<p>The information an expandable button conveys to AT depends heavily on the context. This context also dictates which ARIA attributes should (or should not) be used. Often, the patterns are very similar in their functionality and interpretation and can be interpreted differently by me than by you.</p>
<p>When I conduct accessibility audits, I usually can’t avoid having to check an element that has the <code>aria-expanded</code> attribute. One of the challenges I faced was determining what kind of pattern this was, whether <code>aria-expanded</code> was appropriate here, or if it could even be replaced by a native HTML element.</p>
<p>This article is intended to help you more easily determine whether <code>aria-expanded</code> has been used correctly or not. It’s not about how to implement every pattern discussed here in a production environment! However, in each section, I’ve included additional links so you can dive deeper into the topic.</p>
<div><h2>FYI</h2>
<p>In the following, I will refer to the <a href="https://www.w3.org/WAI/ARIA/apg/patterns/"><em>ARIA Authoring Practices Guide</em> (APG)</a>. It is important to note that the implementations presented there should be understood as proof-of-concept and <em>not</em> ready-to-use accessible patterns! In <a href="https://adrianroselli.com/2019/02/uncanny-a11y.html#APG"><em>Uncanny A11y</em></a>, Adrian Roselli explains in detail why you should be cautious to use these patterns (without testing them thoroughly).</p>
</div>
<p></p>
<h2>The two categories</h2>
<p>As I understand it, collapsible widgets can be divided into two categories: collapsible sections and collapsible interactive elements.</p>
<p>These two categories include patterns that are sometimes very similar, but not identical. In addition, there are patterns that can also reveal and hide content, but these do not require the <code>aria-expanded</code> attribute.</p>
<h2>Collapsible sections</h2>
<p>This category contains two patterns: Accordion and disclosure widget. The former is based on the latter, so let’s take a closer look at the disclosure widget first.</p>
<h3>Disclosure widget</h3>
<pre><code>&lt;button
	aria-controls="content"
	aria-expanded="false"
	type="button"
&gt;
	Show more
&lt;/button&gt;
&lt;div id="content" &gt;
	&lt;p&gt;I am hidden no more!&lt;/p&gt;
&lt;/div&gt;
</code></pre>
<p>This is a basic disclosure widget that requires JavaScript to dynamically change <code>aria-expanded</code> from <code>true</code> to <code>false</code> and to add keyboard support.</p>
<p><code>aria-controls</code> serves as optional support here to establish a direct link between the button and the content. For more information on implementation, see Adrian Roselli’s article <a href="https://adrianroselli.com/2020/05/disclosure-widgets.html"><em>Disclosure Widgets</em></a>.</p>
<p>However, if that is all it needs to do, it is recommended to use the <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/details"><code>&lt;details&gt;</code> and <code>&lt;summary&gt;</code></a> elements. These two native HTML elements work in all major browsers and are also recognised by AT.</p>
<h3>Accordion</h3>
<p>The accordion pattern is more complex because it combines multiple disclosure widgets. Here, too, <code>aria-expanded</code> is required to communicate the current state to assistive technologies.</p>
<p>There is also often the “exclusive accordion,” which only allows one disclosure widget to be open at a time and closes the others. Not only has Eric Eggert expressed legitimate doubts about this in his article <em><a href="https://yatil.net/blog/exclusive-accordions">Exclusive accordions exclude</a></em>, but Steven Hoober also explains in <a href="https://www.uxmatters.com/mt/archives/2020/05/designing-for-progressive-disclosure.php"><em>Designing for Progressive Disclosure</em></a> why this is a user-hostile pattern.</p>
<p>If you still need to build one, it’s recommended to try out Alexander Lehner’s solution in <a href="https://www.oidaisdes.org/blog/lets-play-accordion/"><em>Let’s Play Accordion with the HTML details element</em></a>. Alternatively, Heydon Pickering also has a suggestion on how <a href="https://inclusive-components.design/collapsible-sections/">collapsible sections</a> could be implemented.</p>
<h2>Collapsible interactive elements</h2>
<p>A collapsible section can also contain interactive elements such as links, but this pattern is most often used to hide large amounts of text in order to save vertical space. You can read about why this can be user-hostile in the linked articles from the previous section.</p>
<div><h2>FYI</h2>
<p>While it is technically possible to nest interactive elements within one another, this should be avoided due to potential accessibility issues. Links within the body text of a disclosure widget are perfectly fine. However, you should avoid placing complex interactive patterns within the disclosure widget. For an introduction to this issue, see the article <a href="https://adrianroselli.com/2016/12/be-wary-of-nesting-roles.html"><em>Be Wary of Nesting Roles</em></a> by Adrian Roselli.</p>
</div>
<p>I was able to identify four patterns for collapsible interactive elements that would require <code>aria-expanded</code>: menus, navigation, tree views and combo box.</p>
<h3>Navigation</h3>
<p>Perhaps because menus and navigation behave so similar, they are often confused with one another. That’s why, in my article <a href="https://www.stevefrenzel.dev/posts/menu-and-navigation-the-difference/"><em>Menu and Navigation: The Difference</em></a>, I explain how to tell them apart. What they do have in common, however, is that when the content becomes extensive, so-called <a href="https://www.w3.org/WAI/tutorials/menus/flyout/">fly-out menus</a> are used to show and hide content:</p>
<pre><code>&lt;nav aria-labelledby="main-nav"&gt;
	&lt;span hidden id="main-nav"&gt;Main&lt;/span&gt;
	&lt;ul&gt;
		&lt;li&gt;&lt;a href="…"&gt;Home&lt;/a&gt;&lt;/li&gt;
		&lt;li&gt;&lt;a href="…"&gt;Shop&lt;/a&gt;&lt;/li&gt;
		&lt;li class="has-submenu"&gt;
			&lt;a href="…" aria-expanded="false"&gt;
				Space Bears
			&lt;/a&gt;
			&lt;ul&gt;
				&lt;li&gt;&lt;a href="…"&gt;Space Bear 6&lt;/a&gt;&lt;/li&gt;
				&lt;li&gt;&lt;a href="…"&gt;Space Bear 6 Plus&lt;/a&gt;&lt;/li&gt;
			&lt;/ul&gt;
		&lt;/li&gt;
		&lt;li&gt;&lt;a href="…"&gt;Mars Cars&lt;/a&gt;&lt;/li&gt;
		&lt;li&gt;&lt;a href="…"&gt;Contact&lt;/a&gt;&lt;/li&gt;
	&lt;/ul&gt;
&lt;/nav&gt;
</code></pre>
<p>In this slightly altered W3C example of a navigation we can see <a href="https://piccalil.li/blog/its-about-time-i-tried-to-explain-what-progressive-enhancement-actually-is/">progressive enhancement</a> in action:</p>
<ul>
<li>If CSS and JavaScript fail to load, this navigation would still work because semantic HTML was used and none of the list items are hidden.</li>
<li><code>aria-expanded</code> indicates that the submenu is currently collapsed.</li>
<li>No additional ARIA is needed to describe the relationships between the list items and their respective parent lists, as this is communicated by the semantic HTML elements <code>&lt;ul&gt;</code> and <code>&lt;li&gt;</code>.</li>
</ul>
<div><h2>FYI</h2>
<p>There is currently no native solution for this pattern that works entirely without JavaScript. However, the proposed <a href="https://www.stevefrenzel.dev/posts/my-thoughts-on-the-focusgroup-attribute-proposal/"><code>focusgroup</code> attribute</a> might eventually fill this gap.</p>
</div>
<p>There is another element related to this pattern that requires the <code>aria-expanded</code> attribute: the so-called hamburger button!</p>
<h3>Menu</h3>
<p>It is very important here to distinguish whether this button is intended to reveal a navigation bar or a menu. If it is a navigation bar, <code>aria-expanded</code> is sufficient. If it is a menu, <a href="https://w3c.github.io/aria/#aria-haspopup"><code>aria-haspopup</code></a> is also required!</p>
<pre><code>&lt;button
  aria-controls="menu"
  aria-haspopup="true"
  aria-expanded="false"
  id="menu-button"
  type="button"
&gt;
  Menu
&lt;/button&gt;
&lt;ul
  aria-activedescendant="mi1"
  aria-labelledby="menu-button"
  id="menu"
  role="menu"
  tabindex="-1"
&gt;
  &lt;li id="mi1" role="menuitem"&gt;Action 1&lt;/li&gt;
  &lt;li id="mi2" role="menuitem"&gt;Action 2&lt;/li&gt;
  &lt;li id="mi3" role="menuitem"&gt;Action 3&lt;/li&gt;
  &lt;li id="mi4" role="menuitem"&gt;Action 4&lt;/li&gt;
&lt;/ul&gt;
</code></pre>
<p>Depending on the scope of the menu, the following ARIA roles may also be necessary:</p>
<ul>
<li><code>aria-labelledby</code> or <code>aria-label</code>: If a menubar has a visible label, the element with role <code>menu</code> or <code>menubar</code> needs to have one of these attributes set to a value that refers to the labelling element.</li>
<li><code>aria-activedescendant</code>: Indicates the relationship between the selected menu item and the parent element in which it is located.</li>
<li><code>aria-checked</code>: If it is possible to select multiple menu items, the corresponding value must be <code>true</code> or <code>false</code>.</li>
<li><code>aria-controls</code>: Establishes a programmatic relationship between the respective menu button and the contained menu items. As mentioned earlier, browser support is sparse, and this feature should be considered more of a nice-to-have.</li>
</ul>
<p>A very good guide to creating progressively enhanced menus is Heydon’s article <a href="https://inclusive-components.design/menus-menu-buttons/"><em>Menus &amp; Menu Buttons</em></a>. The APG explainer for the <a href="https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/"><em>Menu Button Pattern</em></a> is a good place to start if you want to get an overview of the necessary ARIA roles.</p>
<h3>Tree view</h3>
<p>APG distinguishes between two patterns here that are essentially the same but can vary greatly in complexity depending on the implementation:</p>
<ul>
<li><a href="https://www.w3.org/WAI/ARIA/apg/patterns/treeview/">Tree view</a>: “A tree view widget presents a hierarchical list.”</li>
<li><a href="https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/">Tree grid</a>: “A tree grid widget presents a hierarchical data grid consisting of tabular information that is editable or interactive.”</li>
</ul>
<p>In both cases, the <code>aria-expanded</code> attribute is required for the interactive element, which can show or hide additional elements. Here, too, other ARIA roles may be necessary depending on the implementation. This simplified example shows a possible HTML structure. It would also need a significant amount of CSS and JavaScript to convey visual information, as well as information communicated to AT:</p>
<pre><code>&lt;ul role="tree" aria-labelledby="tree-label"&gt;
  &lt;span hidden id="tree-label"&gt;Menu&lt;/span&gt;
  &lt;li
    aria-expanded="false"
    aria-level="1"
    aria-posinset="1"
    aria-selected="false"
    aria-setsize="2"
    role="treeitem"
  &gt;
    &lt;ul role="group"&gt;
      &lt;li
        aria-level="2"
        aria-posinset="1"
        aria-selected="false"
        aria-setsize="2"
        role="treeitem"
      &gt;
        Content 1
      &lt;/li&gt;
      &lt;li
        aria-level="2"
        aria-posinset="2"
        aria-selected="false"
        aria-setsize="2"
        role="treeitem"
      &gt;
        Content 2
      &lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;
</code></pre>
<ul>
<li><a href="https://www.w3.org/TR/wai-aria-1.2/#aria-multiselectable"><code>aria-multiselectable</code></a>: Necessary if more than one node can be selected.</li>
<li><code>aria-selected</code> or <code>aria-checked</code>: One of the two is required if more than one node can be selected.</li>
<li><code>aria-labelledby</code> or <code>aria-label</code>: The element with role <code>tree</code> has either a visible label referenced by <code>aria-labelledby</code> or a value specified for <code>aria-label</code>.</li>
<li><a href="https://www.w3.org/TR/wai-aria-1.2/#aria-orientation"><code>aria-orientation</code></a>: If the <code>tree</code> element is horizontally oriented, it has the value <code>aria-orientation="horizontal"</code>.</li>
<li><a href="https://www.w3.org/TR/wai-aria-1.2/#aria-level"><code>aria-level</code></a>, <a href="https://www.w3.org/TR/wai-aria-1.2/#aria-setsize"><code>aria-setsize</code></a> and <a href="https://www.w3.org/TR/wai-aria-1.2/#aria-posinset"><code>aria-posinset</code></a>: These might be necessary, if “the complete set of available nodes is not present in the DOM due to dynamic loading as the user moves focus in or scrolls the tree”.</li>
<li><code>tabindex</code>: Depending on your implementation, you might need to implement a <a href="https://webaim.org/techniques/keyboard/tabindex#zero-negative-one">roving <code>tabindex</code></a>.</li>
</ul>
<p>If you need a starting point for creating one of these components, check out <a href="https://blog.pope.tech/2023/07/06/create-an-accessible-tree-view-widget-using-aria/"><em>Create an accessible tree view widget using ARIA</em></a> by Pope Tech.</p>
<h3>Combo box</h3>
<p><a href="https://nerdy.dev/nice-select">The native <code>&lt;select&gt;</code> element can now be styled freely</a> and also offers keyboard support, as well as robust accessibility support by default, so there should be no need to build this element yourself.</p>
<pre><code>&lt;label for="pet-select"&gt;Choose a pet:&lt;/label&gt;

&lt;select id="pet-select" name="pets"&gt;
  &lt;option value=""&gt;Please choose an option:&lt;/option&gt;
  &lt;option value="dog"&gt;Dog&lt;/option&gt;
  &lt;option value="cat"&gt;Cat&lt;/option&gt;
&lt;/select&gt;
</code></pre>
<p>If it is necessary after all, the <code>aria-expanded</code> attribute is required here as well. In addition, <code>aria-haspopup</code>, <code>aria-activedescendant</code>, and <code>aria-selected</code> may also be necessary, depending on the implementation. Furthermore, <code>aria-autocomplete</code> and <code>aria-labelledby</code> or <code>aria-label</code> may also be required.</p>
<p>Make sure to give the <a href="https://w3c.github.io/aria/#combobox">specs of</a> <a href="https://www.w3.org/TR/wai-aria-1.2/#combobox"><code>combobox</code></a> <a href="https://w3c.github.io/aria/#combobox">role</a> a good read before building it. Although, why go through all that trouble when the web platform provides an element that can already do all of this? 🤗</p>
<p></p>
<h2>Similar but different</h2>
<p>The following patterns can also reveal content at the touch of a button. However, this isn't so much a “fold-out” as it is a “pop-up”! Instead of using <code>aria-expanded</code> to communicate the state of an interactive element, you would rather use <code>aria-haspopup</code> oder <code>aria-modal</code>.</p>
<h3>Dialog (Modal)</h3>
<p><a href="https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/">According to APG, the dialog pattern requires the <code>aria-modal</code> attribute</a>, but let me stop you right there. <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog">The native <code>&lt;dialog&gt;</code> element</a> has had solid browser support for quite some time and is supported by assistive technologies. In addition, we now have <a href="https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API">invoker commands</a> at our disposal, which means you <em>could</em> implement this pattern without using any JavaScript! <a href="https://www.scottohara.me/blog/2023/01/26/use-the-dialog-element.html">Use the dialog element (reasonably)</a> to save time and frustration and to keep your users happy.</p>
<p>In this example, we’re using invoker commands. If you want to play it safe, you could also add a fallback using JavaScript, in case invoker commands are not supported yet in the browser of your choice.</p>
<pre><code>&lt;button command="show-modal" commandfor="my-dialog"&gt;
	Open dialog
&lt;/button&gt;

&lt;dialog id="my-dialog"&gt;
	&lt;h2&gt;Progressive enhancement&lt;/h2&gt;
  &lt;p&gt;This dialog uses no JavaScript!&lt;/p&gt;
  &lt;button commandfor="my-dialog" command="close"&gt;
	  Close
  &lt;/button&gt;
&lt;/dialog&gt;
</code></pre>
<h3>Tabbed interfaces</h3>
<p>Unfortunately, there is no native, JavaScript-free solution for this pattern yet, so you’ll either have to build it yourself using <code>aria-haspopup</code> or use a ready-made solution from a third-party provider.</p>
<p>Personally, I prefer the first option so you know what’s going on under the hood. A good starting point for this is <a href="https://inclusive-components.design/tabbed-interfaces/"><em>Tabbed Interfaces</em></a> by Heydon Pickering.</p>
<p>Additionally (<a href="https://www.w3.org/WAI/ARIA/apg/patterns/tabs/">according to the APG</a>), other ARIA roles such as <code>aria-controls</code>, <code>aria-selected</code>, <code>aria-orientation</code>, <code>aria-label</code>, or <code>aria-labelledby</code> may be added, depending on how you implement it.</p>
<h3>Tooltip</h3>
<p>This pattern is an interesting case, as there is <a href="https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/">no specific example of it in the APG</a>. Nevertheless, Heydon has taken on the challenge here as well and explains in <a href="https://inclusive-components.design/tooltips-toggletips/"><em>Tooltips &amp; Toggletips</em></a> how to create an accessible tooltip on your own.</p>
<p>Alternatively, you could experiment with how the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Popover_API">Popover API</a> and invoker commands work together with AT to implement this pattern natively and without JavaScript:</p>
<pre><code>&lt;button command="toggle-popover" commandfor="info"&gt;
  What is two-factor authentication?
&lt;/button&gt;

&lt;div id="info" popover="auto" role="tooltip"&gt;
  Two-factor authentication adds a second verification step (like a code
  from your phone) when you log in.
&lt;/div&gt;
</code></pre>
<p>Once again, we need very little to get a lot done! Let’s break down what’s happening here:</p>
<ul>
<li><code>command="toggle-popover"</code>: The Popover API provides to show or hide a popover, aka toggling it.</li>
<li><code>commandfor="info"</code>: Here we’re using the Popover API to target the element with <code>id="info"</code> in order to connect the button with this particular element.</li>
<li><code>popover="auto"</code>: This enables the so-called “light-dismiss”, meaning that clicking outside of the tooltip or pressing <kbd>ESC</kbd> will close it.</li>
<li><code>role="tooltip"</code>: Without explicitly specifying the role, it would have a role of “group”.</li>
</ul>
<h2>Wrapping up</h2>
<p>Originally, this article was supposed to be about expandable buttons. Then I realised it would make more sense to write specifically about the ARIA role <code>aria-expanded</code>. After further research, I discovered that this attribute is no longer absolutely necessary for some patterns because the web platform has evolved significantly in recent years!</p>
<p>Thanks to native solutions like <code>&lt;detail&gt;</code>, <code>&lt;summary&gt;</code>, and <code>&lt;dialog&gt;</code>, as well as the Popover and Invoker Commands API, it’s possible to implement many of the patterns discussed here with minimal effort and even without JavaScript.</p>
<p>Nevertheless, it’s very important that not only these native (and in some cases very new) solutions are thoroughly tested with assistive technology, but also those you’ve created yourself.</p>
<p>It’s even more important to note that the APG patterns are not suitable for production use but should be understood solely as illustrations of how to use various ARIA roles. That’s why I’ve added an alternative example to each pattern presented, in which it was implemented with progressive enhancement in mind.</p>
        
        ]]></description>
        
      </item>
    
      <item>
        <title>Proxy and Reflect</title>
        <link>https://piccalil.li/blog/proxy-and-reflect/?ref=articles-rss-feed</link>
        <dc:creator><![CDATA[Mat Marquis]]></dc:creator>
        <pubDate>Thu, 09 Jul 2026 11:55:00 GMT</pubDate>
        <guid isPermaLink="true">https://piccalil.li/blog/proxy-and-reflect/?ref=articles-rss-feed</guid>
        <description><![CDATA[<div><h2>FYI</h2>
I'm Mat, author of Piccalilli's very own [JavaScript for Everyone](https://piccalil.li/javascript-for-everyone), a course designed to help you make the jump from junior- to senior developer. As ever, I'm here to teach you JavaScript — not just the _what_, but the _how_ and the _why_ of JavaScript.
<p>In this <em>specific</em> instance, I'm here to teach you about using the <code>Proxy</code> constructor and <code>Reflect</code> object. These are features of the language worth having in your toolbox, naturally — but just as importantly they allow us to graze up against some of JavaScript's innermost workings, and in doing so, better learn the shape of the mechanisms that power the language. That's the kind of know-how that makes a <em>senior</em> developer.</p>
<p>Now, the keen-eyed among you may notice that this article is shaped conspicuously like an excerpted lesson from said course — and yet, nowhere on the <a href="https://piccalil.li/javascript-for-everyone/lessons">lesson listing page</a> does it appear. "Whatever could <em>that</em> mean," you might ask.</p>
<p>Well, <a href="https://piccalil.li/javascript-for-everyone#sign-up">stay tuned</a>.
</p>
<p>An object is a collection of properti— <em>hey</em>! Don't you roll your eyes at me! Listen, I know that phrase is approaching "mitochondria is the powerhouse of the cell"-level here, but I'm going somewhere new with this, I promise.</p>
<p>Ahem. <em>An object is a collection of properties</em>, internal slots, and internal methods that allow us to interact with those properties. When you punch <code>({}).theProperty</code> into your developer console, you do so expecting the following choose-your-own-adventure operation to kick off:</p>
<ul>
<li>Is that key somewhere along the object's prototype chain?
<ul>
<li>Yes.
<ul>
<li>Is it a data property?
<ul>
<li>Result in the property descriptor's <code>value</code>.</li>
</ul>
</li>
<li>Is it an accessor property?
<ul>
<li>Invoke the getter method, and result in the value returned by the that method.</li>
</ul>
</li>
</ul>
</li>
<li>No.
<ul>
<li>Result in <code>undefined</code>.</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>This use of property accessor syntax doesn't <em>itself</em> represent all those steps — rather, dot notation is the API that kicks off an <a href="https://tc39.es/ecma262/#sec-ordinary-object-internal-methods-and-internal-slots-get-p-receiver">internal <code>[[Get]]</code> operation defined by the specification</a>, and the steps taken by that <code>[[Get]]</code> operation determine the result.</p>
<p>We can't get in there and tinker with the specific steps taken by an object's internal methods, nor would we likely want to — that's JavaScript engine turf. What we can do is <em>intercept</em> those operations by way of a <strong>proxy</strong> object, and in doing so we can alter, expand, or wholesale <em>redefine</em> the way that an object works, at its most fundamental levels.</p>

<p>See? And I bet you thought this one was gonna be boring.</p>

<p>The <code>Proxy</code> constructor can be used to create an object that acts as a proxy for a target object, allowing you to intercept and redefine operations performed on the latter by using the former as an intermediary.</p>
<p>When invoked with <code>new</code>, <code>Proxy</code> results in an object — no surprises there. It accepts two arguments: a <strong>target object</strong> and a <strong>handler object</strong>:</p>
<pre><code>const targetObject = {
  theProperty: "A string."
};
const handlerObject = {};
const theProxyObject = new Proxy( targetObject, handlerObject );

console.log( theProxyObject );

/* Result (Firefox, expanded):
Proxy { &lt;target&gt;: {…}, &lt;handler&gt;: {} }
  &lt;target&gt;: Object { theProperty: "A string." }
  &lt;handler&gt;: Object {  }
*/

/* Result (Chrome, expanded):
Proxy(Object) {theProperty: 'A string.'}
  [[Handler]]: Object
  [[Target]]: Object
  [[IsRevoked]]: false
*/
</code></pre>
<p>Nothing <em>too</em> surprising in the console, here. Our newly-minted proxy object contains a reference to our target object and a set of internal slots, using that <code>&lt;&gt;</code> or <code>[[]]</code> notation — depending on the browser — which makes it clear that we're not meant to interact with these slots <em>directly</em>, the way we would interact with a string-based property key. <code>[[Target]]</code> is an internal slot representing the the object we want to act on, complete with the property we defined. <code>[[Handler]]</code> represents the handler object that acts as an intermediary for interacting with the target object.</p>

<p>I bet that <code>[[isRevoked]]</code> in Chrome jumped out at you right away. Don't worry, we haven't hit upon some big 2010-style discrepancy in browser behavior — just a difference in how an internal slot is surfaced. We’ll get there in a bit.</p>

<p>If we change the value associated with the property we've defined on the target object, that change is reflected by the proxy object's reference to it:</p>
<pre><code>const targetObject = {
  theProperty: "A string."
};
const handlerObject = {};
const theProxyObject = new Proxy( targetObject, handlerObject );

targetObject.theProperty = "Something else.";

console.log( theProxyObject );
/* Result (expanded):
Proxy { &lt;target&gt;: {…}, &lt;handler&gt;: {} }
  &lt;target&gt;: Object { theProperty: "Something else." }
  &lt;handler&gt;: Object {  }
*/
</code></pre>
<p>At a glance, this feels like some classic "by-reference" stuff — "object values are stored by reference," "objects are a collection of properties," "objects are the powerhouse of the script," <em>et cetera</em>. Remember, however, that we're not talking about variables or properties — in this case, the proxy object is <em>itself</em> a reference to the target object. That proxy object can be used in place of the target object, wholesale:</p>
<pre><code>const targetObject = {
  theProperty: "A string."
};
const handlerObject = {};
const theProxyObject = new Proxy( targetObject, handlerObject );

console.log( theProxyObject.theProperty );

// Result: A string.
</code></pre>
<p>Accessing a property of the proxy object is effectively accessing that property on the target object, <em>by way of</em> the proxy object. You can't define an own property on the proxy object as usual, either — instead, that property will be defined on the target:</p>
<pre><code>const targetObject = {
  theProperty: "A string."
};
const handlerObject = {};
const theProxyObject = new Proxy( targetObject, handlerObject );

theProxyObject.theOtherProperty = "Another string";

console.log( theProxyObject );

/* Result (expanded):
Proxy { &lt;target&gt;: {…}, &lt;handler&gt;: {} }
  &lt;target&gt;: Object { theProperty: "A string.", theOtherProperty: "Another string" }
  &lt;handler&gt;: Object {  }
*/
</code></pre>
<p>Used the way you've seen it so far here, well, we've basically just created an unusual reference value with extra steps, but that's only because we're not asking the handler object to <em>do</em> anything by way of our proxy object — here, the handler object is basically acting as a translator from one language to that same language. The use case becomes a little more clear when we start creating <strong>handler functions</strong> on its handler object, sometimes called <strong>traps</strong>:</p>
<pre><code>const targetObject = {
  theProperty: "A string."
};
const handlerObject = {
  get() {
    return "Something else entirely.";
  }
};
const theProxyObject = new Proxy( targetObject, handlerObject );

console.log( theProxyObject.theProperty );
// Result: "Something else entirely." )
</code></pre>
<p>There’s a <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy#handler_functions">corresponding trap for every operation that can be performed on an object</a>, all named in relatively predictable ways. That <code>get()</code> method defined on the handler object is a trap for the <code>[[Get]]</code> internal method, which is fired whenever you attempt to access the value of an object property. By making it return an explicit value this way, well, we messed up a perfectly good <code>[[Get]]</code> operation is what we did — we've intercepted that operation and changed what should result from it. Now no matter what we do with our <code>targetObject</code>, attempting to access the value of any property will result in exactly what we said it should:</p>
<pre><code>const targetObject = {
  theProperty: "A string."
};
const handlerObject = {
  get() {
    return "You've just activated my trap function!";
  }
};
const theProxyObject = new Proxy( targetObject, handlerObject );

console.log( theProxyObject );

/* Result (expanded):
Proxy { &lt;target&gt;: {…}, &lt;handler&gt;: {…} }
  &lt;target&gt;: Object { theProperty: "A string." }
  &lt;handler&gt;: Object { get: get() }
*/

targetObject.newProperty = true;

console.log( theProxyObject );

/* Result (expanded):
Proxy { &lt;target&gt;: {…}, &lt;handler&gt;: {…} }
  &lt;target&gt;: Object { theProperty: "A string.", newProperty: true }
  &lt;handler&gt;: Object { get: get() }
*/

console.log( theProxyObject.newProperty );
// Result: You've just activated my trap function!

console.log( theProxyObject[ "newProperty" ] );
// Result: You've just activated my trap function!
</code></pre>
<p>And like any function, we can have that trap function perform whatever tasks we want:</p>
<pre><code>const targetObject = {
  theProperty: true
};
const handlerObject = {
  get() {
    console.log( "Psych!" );
    return false;
  }
};
const theProxyObject = new Proxy( targetObject, handlerObject );

console.log( theProxyObject.theProperty );

/* Result:
Psych!
false
*/
</code></pre>
<p>Naturally, that includes manipulating the results of those operations with a little more finesse than a <code>console.log</code> and a string. The <code>get()</code> method of a handler object accepts three arguments: one representing the target object, one representing the key for the property being accessed, and one representing the "receiver," which is a little higher-concept: the receiver argument represents the value of <code>this</code> within the context of the getter method — a reference to <a href="https://piccalil.li/blog/javascript-what-is-this/">the object bound to the</a> <code>get</code> <a href="https://piccalil.li/blog/javascript-what-is-this/">method</a> <a href="https://piccalil.li/blog/javascript-when-is-this/">at the time when that method is invoked</a> — that might sound fraught, as <code>this</code> is wont to be, but in most cases that will be the target object.</p>
<p>Given these arguments, we're able to use our <code>get</code> method to access and manipulate the values of our target object's properties:</p>
<pre><code>const targetObject = {
  theProperty: 10
};
const handlerObject = {
  get( target, propertyKey, receiver) {
    return target[ propertyKey ] * 2;
  }
};
const theDoubleObject = new Proxy( targetObject, handlerObject );

console.log( theDoubleObject.theProperty );
// Result: 20
</code></pre>
<div><h2>FYI</h2>  
Okay, back to that `[[isRevoked]]` now that you know what would _be_ revoked. There's a second way of creating a proxy object: the `Proxy.revocable()` factory function.
<pre><code>const targetObject = {};
const handlerObject = {};
const revocableProxy = Proxy.revocable( targetObject, handlerObject );
</code></pre>
<p>The object that results from calling <code>Proxy.revocable</code> (with the same target and handler arguments as <code>new Proxy</code>) will contain two properties. The first property, <code>proxy</code>, pretty predictably contains a proxy object, and the value of this property is identical to the proxy object that would result from using <code>Proxy</code> as a constructor with those same arguments.</p>
<p>The second property is a <code>revoke</code> method that can be used to detach that proxy object from its target object:</p>
<pre><code>const revocableProxy = Proxy.revocable({}, {});

console.log( revocableProxy );

/* Result (expanded):
Object { proxy: Proxy, revoke: () }
  proxy: Proxy { &lt;target&gt;: {}, &lt;handler&gt;: {} }
    &lt;target&gt;: Object {  }
    &lt;handler&gt;: Object {  }
  revoke: function ()
*/

/* Result (Chrome, expanded):
Object { proxy: Proxy, revoke: () }
  proxy: Proxy(Object)
    [[Handler]]: Object
    [[Target]]: Object
    [[IsRevoked]]: false
  revoke: ƒ ()
*/
</code></pre>
<p>Calling <code>revoke()</code> un-proxies your object — once invoked, the proxy object that was returned by <code>Proxy.revocable()</code> will no longer hold a reference to your target or handler object:</p>
<pre><code>const revocableProxy = Proxy.revocable({}, {});

revocableProxy.revoke();

console.log( revocableProxy );

/* Result (expanded):
Object { proxy: Proxy, revoke: () }
  proxy: Proxy { &lt;target&gt;: {}, &lt;handler&gt;: {} }
    &lt;target&gt;: null
    &lt;handler&gt;: null
  revoke: function ()
*/

/* Result (Chrome, expanded):
Object { proxy: Proxy, revoke: () }
  proxy: Proxy(Object)
    [[Handler]]: null
    [[Target]]: null
    [[IsRevoked]]: true
  revoke: ƒ ()
*/
</code></pre>
<p>Just keep in mind that "revoked" means <em>revoked</em> — once <code>revoke</code> is called, there are no take-backs. If no other references to the proxy object exist, it becomes eligible for garbage collection — likewise your target and handler objects, if not referenced elsewhere.</p>
<p>Chrome's JavaScript engine exposes that [[isRevoked]] internal slot in the developer console for at-a-glance debugging purposes, I assume — there's nothing in the language (currently) that gives us direct access to the value of that internal slot. Revoking a proxy works just as well in either browser, no worries there.</p>
</div>
<p>Every one of an object's internal methods has a corresponding trap, which means that you're able to alter the basal behavior of <em>any object, at every possible level, language-wide</em>:</p>
<pre><code>const targetObject = {
  theProperty: "Still here."
};

const handlerObject = {
  deleteProperty( target, key) {
    console.log( "No." );
    return false;
  },
  has( target, key ) {
    console.log( "None of your business." );
    return false;
  },
  getPrototypeOf( target ) {
    console.log( "Who knows?" );
    return null;
  }
};

const theImmovableObject = new Proxy( targetObject, handlerObject );

delete theImmovableObject.theProperty;
/* Result:
No.
false
*/

console.log( theImmovableObject.theProperty );
// Result: Still here.

console.log( "theProperty" in theImmovableObject );
/* Result: 
None of your business.
false
*/

console.log( Object.getPrototypeOf( theImmovableObject ) );
/* Result: 
Who knows?
null
*/
</code></pre>
<p>So, y'know, just make sure you do it right.</p>
<p>No pressure or anything.</p>
<p>Oh, hey, speaking-of:</p>
<p></p>
<h2>Reflect</h2>
<p>You might have noticed a few things about the code examples you've seen so far here. One, we're partying like it's 2009 — accessing properties using <em>olde timey</em> bracket notation.</p>
<p>Two, it's a little unsettling to <code>[[Get]]</code> a property value within the context of changing the way <code>[[Get]]</code>-ing a property value works, using the very syntaxes we’re changing. It works fine, but the vibes, in strict technical terms, are <em>off</em>.</p>
<p>Third, and by <em>far</em> most importantly: breaking with assumptions as major as "when I create a property on an object, it will happen the way I expect" will <em>absolutely</em> lead to issues in our code somewhere down the line, if not right away. Being able to alter the essential nature of objects themselves draws a razor-thin line between "exciting" and "terrifying," especially when it comes to future maintainability, and the idea of working on a codebase littered with objects that <em>may or may not work like objects</em> is the stuff of nightmares.</p>
<p>For that reason, the <a href="https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-set-p-v-receiver">ES-262 specification helpfully outlines the following <strong>invariants</strong></a> for, say, <code>[[Set]]</code> — that is, the rules that a <code>[[Set]]</code> operation must follow:</p>
<blockquote>
<ul>
<li>The result of [[Set]] is a Boolean value.</li>
<li>Cannot change the value of a property to be different from the value of the corresponding target object property if the corresponding target object property is a non-writable, non-configurable own data property.</li>
<li>Cannot set the value of a property if the corresponding target object property is a non-configurable own accessor property that has <code>undefined</code> as its [[Set]] attribute.</li>
</ul>
</blockquote>
<p>Once you start tinkering with how a <code>[[Set]]</code> operation works, well, following those rules is now on you. If I were to write something along the lines of the following:</p>
<pre><code>const handlerObject = {
  set( target, propertyKey, value, receiver) {
    return target[ propertyKey ] = value * 2;
  }
};

const setDoubler = new Proxy( {}, handlerObject );

setDoubler.theProperty = 2;

console.log( setDoubler.theProperty );
// Result: 4
</code></pre>
<p>My <code>[[Set]]</code> operation didn't return a boolean value the way JavaScript expects, per the sacred invariant rules of <code>[[Set]]</code>. I mean, this snippet will still <em>work,</em> in that we’re outside strict mode, the returned value is coerced to a Boolean, and this one happens to be truthy. It won’t work in every context:</p>
<pre><code>"use strict";
const handlerObject = {
  set( target, propertyKey, value, receiver) {
    return target[ propertyKey ] = value * 2;
  }
};

const setDoubler = new Proxy( {}, handlerObject );

setDoubler.theProperty = 0;

console.log( setDoubler.theProperty );

// Result: Uncaught TypeError: proxy set handler returned false for property '"theProperty"'
</code></pre>
<p>We could rewrite this to explicitly <code>return</code> that expected boolean value, naturally, but even dealing with this simple handler method we find ourselves in "I need to be careful to always do <em>this</em> in <em>this</em> way" territory lest we introduce fundamentally bugged objects to our codebase. Nobody needs that.</p>
<p>That brings us to the <code>Reflect</code> object: a collection of static methods, each with the same name and parameters as our proxy handler methods. <code>Reflect</code> gives us some guardrails to address all of the above concerns (bad vibes and all) by providing us with a set of methods for interacting with objects that all ensure we never deviate too far from how objects are <em>meant</em> to work.</p>
<p><code>Reflect</code> is a <strong>namespace object</strong> — an ordinary object made up of static properties and methods, like the <code>Math</code> or <a href="https://piccalil.li/blog/date-is-out-and-temporal-is-in/"><code>Temporal</code></a> objects:</p>
<pre><code>console.log( Reflect );

/* Result (expanded):
  apply: function apply()
  construct: function construct()
  defineProperty: function defineProperty()
  deleteProperty: function deleteProperty()
  get: function get()
  getOwnPropertyDescriptor: function getOwnPropertyDescriptor()
  getPrototypeOf: function getPrototypeOf()
  has: function has()
  isExtensible: function isExtensible()
  ownKeys: function ownKeys()
  preventExtensions: function preventExtensions()
  set: function set()
  setPrototypeOf: function setPrototypeOf()
  Symbol(Symbol.toStringTag): "Reflect"
*/
</code></pre>
<p>Every one of those methods maps to the names of your proxy object handler methods and expects the same parameters, in the same way — their syntax matches the context we're working in much more than bracket notation. That alone works <em>wonders</em> for the vibes, if you ask me:</p>
<pre><code>const handlerObject = {
  set( target, propertyKey, value) {
    return Reflect.set( target, propertyKey, value * 2 );
  }
};

const setDoubler = new Proxy( {}, handlerObject );

setDoubler.theProperty = 2;

console.log( setDoubler.theProperty );
// Result: 4
</code></pre>
<p>No fuss, no muss, and — most importantly — no poring over the ES-262 specification to ensure that we're not accidentally deviating from <em>The Rules of Objects as They Are Played</em>, because <code>Reflect.set()</code> performs the <code>[[Set]]</code> operation we want <em>and</em> returns the expected boolean value, per the specification. With <code>Proxy</code> we can change how objects work, and with <code>Reflect</code> we can make sure our changed objects still work the way objects are <em>meant</em> to.</p>
<p></p>
<h2>Putting it together</h2>
<p>When we put this all together, we can use proxy objects and <code>Reflect</code> to perform tasks like validating data:</p>
<pre><code>const validationHandler = {
  set( target, propertyKey, value, receiver ) {
    if( typeof value === "string" ) {
      return Reflect.set( target, propertyKey, value );
    } else {
      console.error( "This object only accepts strings." );
      return false;
    }
  }
};
const validatedObject = new Proxy({}, validationHandler );

validatedObject.newProperty = true;
// Result: This object only accepts strings.

console.log( validatedObject );
/* Result:
Proxy { &lt;target&gt;: {}, &lt;handler&gt;: {…} }
  &lt;target&gt;: Object {  }
  &lt;handler&gt;: Object { set: set(target, propertyKey, value, receiver) }
*/
</code></pre>
<p>...Or <a href="https://codepen.io/Wilto/pen/dPNNKdJ">setting and maintaining the internal state of an object</a> — for example, the number of times an object's given property has been accessed:</p>
<pre><code>const handlerObject = {
  accessCounter( target, accessed) {
    Reflect.set( target, "timesAccessed", accessed ? accessed + 1 : 1 );
  },
  set( target, key, value) {
    this.accessCounter( target, Reflect.get( target, "timesAccessed" ) );

    return Reflect.set( target, key, value );
  },
  get( target, key) {
    this.accessCounter( target, Reflect.get( target, "timesAccessed" ) );

    return Reflect.get( target, key );
  }
};

const stateObject = new Proxy({}, handlerObject );

console.log( stateObject );

/* Result (expanded):
Proxy { &lt;target&gt;: {…}, &lt;handler&gt;: {…} }
  &lt;target&gt;: Object {  }
  &lt;handler&gt;: Object { accessCounter: accessCounter(accessed), set: set(target, propertyKey, value, receiver), get: get(target, propertyKey, receiver) }
*/

stateObject.newProperty = true;
// Result: true

console.log( stateObject );
/* Result (expanded):
Proxy { &lt;target&gt;: {…}, &lt;handler&gt;: {…} }
  &lt;target&gt;: Object { timesAccessed: 1, newProperty: true }
  &lt;handler&gt;: Object { accessCounter: accessCounter(accessed), set: set(target, propertyKey, value, receiver), get: get(target, propertyKey, receiver) }
*/

console.log( stateObject.newProperty );
// Result: true

console.log( stateObject );
/* Result (expanded):
Proxy { &lt;target&gt;: {…}, &lt;handler&gt;: {…} }
  &lt;target&gt;: Object { timesAccessed: 2, newProperty: true }
  &lt;handler&gt;: Object { accessCounter: accessCounter(accessed), set: set(target, propertyKey, value, receiver), get: get(target, propertyKey, receiver) }
*/
</code></pre>
<p>Those are kind of novelties when we're acting on a single object, sure — but with the <a href="https://piccalil.li/javascript-for-everyone">approaches and syntaxes you've already learned</a> and a little imagination, it isn't hard to see where proxy objects could be used to create an entire state <em>system</em> with just a few more lines of code, and without the need for bulky frameworks or third-party tools:</p>
<pre><code>function reactiveState( target) {
  const subscribed = new Map();

  return new Proxy({
    ...target,
    subscribe( key, callbackFunc) {
        // If it isn't there already, add it to `subscribed`:
        if( !subscribed.has( key ) ) {
          subscribed.set( key, [] );
        }
        // Associate the callback function with the subscribed object:
        subscribed.get( key ).push( callbackFunc );
      }
    }, {
      set( target, key, value, receiver) {
        const result = Reflect.set( target, key, value );

        // If this is the subscribed-to property...
        if( subscribed.has( key ) ) {
          // ...invoke the callback function with the explicit `this` value of the original object:
          subscribed.get( key ).forEach( callbackFunc =&gt; callbackFunc.call( receiver, key ) );
        }

        return result;
      }
    });
}

// Declare a callback function to be called when the state of an object changes:
const callbackLogger = function( key) {
  const enCardinal = new Intl.PluralRules( "en-US" );
  const counter = this[ key ];
  const pluralize = count =&gt; enCardinal.select( counter ) === "one" ? `` : `s`;

  console.info(`${this.component }.${ key } has been changed ${ counter } time${ pluralize( counter ) }.`);
};

const widget = reactiveState({ component: "widget", count: 0 });
const gizmo = reactiveState({ component: "gizmo", otherCounter: 0 });

// When the `count` property of our widget object changes, call the callbackLogger:
widget.subscribe( 'count', callbackLogger );

// When the `otherCounter` property of our gizmo object changes, call the callbackLogger:
gizmo.subscribe( 'otherCounter', callbackLogger );

widget.count++;
// Result: widget.count has been changed 1 time.

widget.count++;
// Result: widget.count has been changed 2 times.

// We're not subscribed to a `count` property for the gizmo object, so nothing happens here:
gizmo.count++;

// But we _are_ subscribed to an `otherCounter` property for the gizmo object:
gizmo.otherCounter++;
// Result: gizmo.otherCounter has been changed 1 time.

widget.count++;
// Result: widget.count has been changed 3 times.
</code></pre>
<p>Now, listen. I don’t want to trot out any <em>more</em> cliches here, but I won't deny that there's an impulse to wrap this lesson up with a call for temperance — to leave you with a warning about how proxy objects are "as powerful as they are dangerous," and "great power-slash-responsibility" and <em>et cetera</em>, then maybe a little bit about how there is, at least, some cold comfort to be found in <code>Reflect</code>. That's not coming from <em>nowhere</em>, that impulse — altering how the principle building blocks of a language work, at their most fundamental levels, can break stuff <em>pretty bad</em>. No two ways about that.</p>
<p>You know me, though: if I wanted to spend my cortisol on "worrying about getting things wrong," I would've gone to medical school or learned PHP. If you ask me, <code>Proxy</code> and <code>Reflect</code> are brand new, shining examples of the enduring spirit of JavaScript: use the language to change the language, and find new ways to solve problems never even imagined by the hundreds of people who've gotten a hand on <a href="https://262.ecma-international.org/16.0/index.html">the ball</a> since 1995.</p>

<p>Well, "brand new" as of 2015.</p>

<p>The <code>Reflect</code> object gives you some vital future-headache-prevention guardrails, and you should use them, of course — that's what they're there for.</p>
<p>I won't tell you to "be careful," though. Get in there and break stuff; there's no better way to learn. Besides, nothin' reloading the page can't fix, right?</p></div>
        
        ]]></description>
        
      </item>
    
      <item>
        <title>Publishing on the Atmosphere with Standard.site</title>
        <link>https://piccalil.li/blog/publishing-on-the-atmosphere-with-standardsite/?ref=articles-rss-feed</link>
        <dc:creator><![CDATA[Declan Chidlow]]></dc:creator>
        <pubDate>Thu, 25 Jun 2026 11:55:00 GMT</pubDate>
        <guid isPermaLink="true">https://piccalil.li/blog/publishing-on-the-atmosphere-with-standardsite/?ref=articles-rss-feed</guid>
        <description><![CDATA[<p><a href="https://standard.site/">Standard.site</a> provides a set of lexicons for publishing long-form content on the internet using the same protocol used under the hood by Bluesky.</p>
<p>If you are wondering what 'lexicons' and 'the Atmosphere' are, don't fret. This article will explain what they mean, why you should care about Standard.site, and walk you through exactly how you can implement Standard.site using some simple JavaScript or a plugin for your favourite content management system.</p>
<h2>What Standard.site is and why you should care</h2>
<p>If Bluesky is the network's answer to short-form microblogging, think of Standard.site as its equivalent for blogs, newsletters, and long-form journalism.</p>
<p>At its core, Standard.site is an open schema that dictates how articles and essays should be formatted as data. When you publish a blog post normally, it lives on your website and relies on scrapers or RSS feeds to be shared. By adopting the Standard.site lexicon, your long-form content becomes a natively understood piece of data on the decentralised web.</p>
<p>One of the most overt benefits we get from defining Standard.site lexicons for our publication are enhanced rich embeds on Bluesky, like this:</p>
<p><img src="https://piccalil.b-cdn.net/images/blog/standard-site-bluesky-post.png" alt="A mock bluesky post showing a linked blog post, featuring a richer UI experience with direct call to action to view publisher" /></p>
<p>We also get many other benefits. For example, I was surprised to see that the professional identity network Sifa ID <a href="https://sifa.id/p/vale.rocks#publications">displayed my posts upon my profile</a>. It was also pleasant to see my posts naturally populate on readers like <a href="https://pckt.blog/">pckt</a>, <a href="https://docs.surf/">Docs.surf</a>, <a href="https://potatonet.app/">potatonet</a>, and <a href="https://leaflet.pub/">Leaflet</a> without any additional work on my part.</p>
<p>The strength of AT Protocol is that data is interoperable and can be shared, which plays into the strength of Standard.site, which is that a single set of well structured-schemas. The result is that various indexers and tools can all work with the available data in their own ways, knowing how it'll be structured. Your content can be moved between hosts without losing your data or the audience you've built, and there is no single controlling authority. To get further into this, however, we must first establish an understanding of the AT Protocol.</p>
<p></p>
<h2>Understanding the AT Protocol</h2>
<p>To implement Standard.site, you will want to understand the Authenticated Transfer Protocol (known colloquially as the 'AT Protocol' or 'atproto') at least at a surface level. The AT Protocol is a decentralised system designed to give users ownership of their data.</p>
<p>To explain it at its simplest, you have a Personal Data Server (PDS) which hosts user accounts. Currently, the largest PDS is provided by Bluesky, but anyone can run their own. A PDS holds lots of user accounts, and each user account can hold records, which are data.</p>
<p>Each user account can be identified by a globally unique DID (Decentralised Identifier) that acts as their permanent ID. A DID looks like this: <code>did:plc:7qg6mz2xtzozxkgbcvf4pdnu</code>. Each account also has a handle, which comes in the form of a DNS record. We can see this in that people on Bluesky's PDS who haven't configured a custom domain for their account have a handle like this: <code>bsky.bsky.social</code>. If you navigate to that in a browser, it'll take you to the Bluesky page: <a href="https://bsky.bsky.social/"></a><a href="https://bsky.bsky.social">https://bsky.bsky.social</a>.</p>
<p>Each account features a data repository that holds collections of JSON records. These JSON records must follow specific structures called lexicons. Lexicons are just schemas, like JSON-Schema or OpenAPI, which define how the JSON must be structured and formatted. Records are put into 'collections', which we can think of as folders. A collection is identified by a Namespace Identifier (NSID) which makes reference to a domain to identify schemas.</p>
<p>Let’s run through an example with Bluesky so we can really get a handle on things. When a user signs up to Bluesky, a <code>self</code> record is created in the <code>app.bsky.actor.profile</code> collection of that user's data repository with information about the account, like its name and profile description. Piccalilli's looks something like this:</p>
<pre><code>{
  "uri": "at://did:plc:lyk2pixxcmyeu4jrapaq26fy/app.bsky.actor.profile/self",
  "cid": "bafyreihzo3igobmunvk6tmsaqgyatyw5gona4kiayvm2f62lymc5dzqjvu",
  "value": {
    "$type": "app.bsky.actor.profile",
    "createdAt": "2024-08-01T12:44:51.324Z",
    "description": "Level up your front-end skills. Stay for the approachable, friendly content and go away with transferable skills you can use day to day.",
    "displayName": "Piccalilli"
  }
}
</code></pre>
<p>Then, every time a post is made, or the Piccalilli account likes something, or blocks someone, or does anything else on Bluesky, a new record is created to represent that action. For instance, when Piccalilli reposts something, a new record is created under the <code>app.bsky.feed.repost</code> collection.</p>
<p><strong>Almost everything is a record, inside a collection, under a user account (identified by a DID), on a PDS.</strong></p>
<p>Notably, everything on ATProto is public. There is no concept of private records, which means we can go out and inspect or reference all the data on the protocol. There are a number of tools for inspecting AT Protocol data, but <a href="https://atproto.at/">Taproot</a> and <a href="https://pdsls.dev/">PDSLs</a> are my favourites. Search for your account handle, and you'll be greeted by your underlying records. Have a poke around to help wrap your head around the structure and how everything fits together.</p>
<p></p>
<h2>The two core records</h2>
<p>Now we've (hopefully) got at least (somewhat) of a (fledgeling) understanding of AT Protocol, we can start hooking up Standard.site. To get started with Standard.site, we need to create two specific types of records in your AT Protocol repository:</p>
<ol>
<li>A Publication Record, which defines information about our publication itself.</li>
<li>Document Records, which contain information about individual articles themselves.</li>
</ol>
<p>It is these records that Bluesky and the rest of the Atmosphere will reference. You can <a href="https://atproto.com/guides/writing-data#writing-data">create them any one of a number of ways</a>. AT Protocol is very open, and you can create records via a variety of methods, but for the purposes of this article, I'll be showing a JavaScript approach using the official <a href="https://npmx.dev/package/@atproto/api"><code>@atproto/api</code></a> npm package.</p>
<p>You will need to authenticate to create these records. The easiest way to do so is by creating an app password under Privacy and Security in Bluesky's settings. An app password gives access to your account and looks like this: <code>fg2g-xob3-xl78-5ezy</code>.</p>
<div><h2>FYI</h2>
<p>An app password gives <em>full</em> access to your account and bypasses multi-factor authentication. Be <em>extremely</em> careful with it. It is a secret, and you should take extreme care of it. This illustrative code shows including the app password inline, but you should consider putting it in an environment variable.</p>
<p>If you think your app password has been made public, you should revoke it, which can be done through the same interface you created it.</p>
</div>
<h3>Publication record</h3>
<p>The first step in support is having a publication record adhering to the <a href="https://standard.site/docs/lexicons/publication/"><code>site.standard.publication</code> lexicon</a>. You only need to create this record once per-publication.</p>
<p>Using the AT Protocol SDK, you can authenticate and create this underlying JSON record for your site with the script below, replacing the template values here with your publication's details.</p>
<p>For the purpose of illustration, this script only sets required properties.</p>
<pre><code>import { AtpAgent } from "@atproto/api";

// Initialise the agent (use your specific PDS if not on Bluesky)
const agent = new AtpAgent({ service: "&lt;https://bsky.social&gt;" });

async function createPublicationRecord() {
  // 1. Authenticate (Always use an App Password, never your main password)
  await agent.login({
    identifier: "your-handle.bsky.social",
    password: "your-app-password",
  });

  const did = agent.session.did;

  // 2. Define the Publication Record
  const publicationRecord = {
    $type: "site.standard.publication",
    url: "&lt;https://example.com&gt;",
    name: "My Awesome Blog",
  };

  // 3. Write the record to your repository
  try {
    const response = await agent.com.atproto.repo.createRecord({
      repo: did,
      collection: "site.standard.publication",
      record: publicationRecord,
    });

    console.log("Publication record created!");
    console.log("Your AT-URI is:", response.data.uri);
  } catch (error) {
    console.error("Failed to create publication:", error);
  }
}

createPublicationRecord();
</code></pre>
<p>If this script was successful, it should output a message reading 'Publication record created!', followed by an AT-URI. Save this, because we'll need it later. In the future if we need to amend this record, we can <a href="https://atproto.com/guides/writing-data#updating-records">revise the record directly</a>.</p>
<h3>Theming</h3>
<p>Though the above script is great, we can take it a bit further and add some more pizazz by <a href="https://standard.site/docs/lexicons/theme/">defining a theme</a>. You can create a theme to lend some more style to how your content displays in readers and how Bluesky embeds it. This is done by adding theming fields to your publication record. You need to specify a <code>background</code>, <code>foreground</code>, <code>accent</code>, and <code>accentForeground</code>. If you're setting any of these values, you must set <em>all</em> of them.</p>
<p>Bluesky uses <code>accent</code> and <code>accentForeground</code> like so:</p>
<p><img src="https://piccalil.b-cdn.net/images/blog/standard-site-theme-diagram.png" alt="A diagram pointing out the accent and accentForeground in the context of the call to action button" /></p>
<p>You should check that your foreground and background and accent and accent foreground all have appropriate contrast. Bluesky previously took these values directly, but now they do <a href="https://bsky.app/profile/esb.lol/post/3mnilfmgqns2d">some contrast adjustment of their own</a>.</p>
<p>You, unfortunately, cannot change the text which appears on the Bluesky embed button, which will always be 'View Publication' if you publish yourself. Some external Standard.site enabled services have their own special buttons with custom text and icons, but these are hard coded in the Bluesky client.</p>
<h3>Verifying your publication</h3>
<p>Next, we have the optional step of creating a file at <code>/.well-known/site.standard.publication</code> containing the AT-URL outputted by our record creation script. This verifies that your domain controls your publication record.</p>
<p>Most services, Bluesky included, don't require this verification. Indeed, some hosts might not let you write to the <code>/.well-known</code> path. However, if you <em>can</em> create a file named <code>site.standard.publication</code> within <code>/.well-known</code> and put your AT-URL within it, your publication will be more widely supported. This verification only needs to be done once.</p>
<p>You can check this has been created correctly by going to your URL on your site. For example, for my personal website, I can visit <a href="https://vale.rocks/.well-known/site.standard.publication">https://vale.rocks/.well-known/site.standard.publication</a> in my browser and see my AT-URI:</p>
<pre><code>at://did:plc:7qg6mz2xtzozxkgbcvf4pdnu/site.standard.publication/3mn2c332ulp2u
</code></pre>
<h3>Document records</h3>
<p>Now that your publication record exists, you need to create per-document records following the <a href="https://standard.site/docs/lexicons/document/"><code>site.standard.document</code> lexicon</a>. Every document needs its own record.</p>
<p>Standard.site supports having your document's content in the record, which is the approach that Offprint, pckt, Leaflet, and some other publishing platforms use. Whether you do this is up to you.</p>
<p>If you <em>do</em> include the content, then it can be displayed natively in Standard.site reader applications, and Bluesky embeds will provide a reading time estimate. For the purpose of this script, I'll again only be including required properties.</p>
<pre><code>import { AtpAgent } from "@atproto/api";
const agent = new AtpAgent({ service: "&lt;https://bsky.social&gt;" });

async function publishDocumentRecrd() {
  await agent.login({
    identifier: "your-handle.bsky.social",
    password: "your-app-password",
  });

  const did = agent.session.did;

  // 1. Define the Document Record
  const documentRecord = {
    $type: "site.standard.document",
    site: `at://your-did/site.standard.publication/your-pub-rkey`, // Full AT-URI of your publication
    title: "My New Post",
    publishedAt: "2026-06-11T00:00:00.000Z",
  };

  // 2. Write the record to your repository
  try {
    const response = await agent.com.atproto.repo.createRecord({
      repo: did,
      collection: "site.standard.document",
      record: documentRecord,
    });

    console.log("Success! Document record published to the Atmosphere:");
    console.log(response.data.uri);
  } catch (error) {
    console.error("Failed to publish document:", error);
  }
}

publishDocumentRecord();
</code></pre>
<p>If this script was successful, it should output a message reading 'Success! Document record published to the Atmosphere:', followed by an AT-URI. Save this, because we need it to verify the document.</p>
<h3>Verifying your document</h3>
<p>To complete the two-way verification, the HTML <code>&lt;head&gt;</code> of your live article must contain a link tag pointing back to the document record you just created:</p>
<pre><code>&lt;link rel="site.standard.document" href="at://did:plc:your-did/site.standard.document/the-record-rkey" /&gt;
</code></pre>
<p>Once these ends are tied together, your webpage and document record point to each other, and clients across the decentralised web can seamlessly reference the record.</p>
<h3>Adding images</h3>
<p>If you look through the Standard.site docs at all, you might notice references to icons and cover images. To make use of these, <a href="https://atproto.com/guides/images-and-video">we must upload a <em>blob</em></a>, which is what unstructured data (like images) within a repository are called.</p>
<p>We need to upload the blob first, so that we can refer to it with a reference in our record. Here is an example of uploading an image as a blob and then referencing it to use it as a cover image.</p>
<pre><code>import fs from "fs";
import { AtpAgent } from "@atproto/api";
const agent = new AtpAgent({ service: "&lt;https://bsky.social&gt;" });

async function uploadImageAndPublishDocument() {
  await agent.login({
    identifier: "your-handle.bsky.social",
    password: "your-app-password",
  });

  const did = agent.session.did;

  // 1. Read the local image file into a buffer
  const imageBuffer = fs.readFileSync("./path/to/your/cover.jpg");

  // 2. Upload the blob to your repository
  const { data: blobResponse } = await agent.com.atproto.repo.uploadBlob(
    imageBuffer,
    { encoding: "image/jpeg" }
  );

  console.log("Blob successfully uploaded!");

  // 3. Define the Document Record, attaching the returned blob reference
  const documentRecord = {
    $type: "site.standard.document",
    site: `at://your-did/site.standard.publication/your-pub-rkey`,
    title: "My New Post with a Cover Image",
    publishedAt: "2026-06-18T12:00:00.000Z",
    cover: blobResponse.blob, // This links the blob to your document
  };

  // 4. Write the document record to your repository
  try {
    const response = await agent.com.atproto.repo.createRecord({
      repo: did,
      collection: "site.standard.document",
      record: documentRecord,
    });

    console.log("Document record with cover image published:");
    console.log(response.data.uri);
  } catch (error) {
    console.error("Failed to publish document:", error);
  }
}

uploadImageAndPublishDocument();
</code></pre>
<p></p>
<h2>Setting up Standard.site on CMS platforms</h2>
<p>If writing JavaScript to push records manually sounds tedious, or you're already using a major content management system, you might prefer to have the process handled for you. How you integrate Standard.site depends very much on how your own site is built, and some platforms have ready-made integrations via plugins that handle all of the above behind the scenes:</p>
<ul>
<li><strong>WordPress:</strong> Has <a href="https://wordpress.org/plugins/atmosphere/">the ATmosphere plugin</a> and <a href="https://wordpress.wireservice.net/">the Wireservice plugin</a>.</li>
<li><strong>CraftCMS:</strong> Has <a href="https://plugins.craftcms.com/standard-site">a plugin simply called Standard.site</a>.</li>
<li><strong>Obsidian:</strong> Has a <a href="https://github.com/SootyOwl/obsidian-standard-site">community plugin</a>.</li>
<li><strong>Static sites</strong>: Generators like 11ty, Hugo, Astro, and Jekyll can use a dedicated CLI tool called <a href="https://sequoia.pub/">Sequoia</a>.</li>
</ul>
<h2>Checking it works</h2>
<p>Obviously, creating all these records and configuring all this Standard.site business is a bit useless if it doesn't actually work. The easiest way to test is by just plopping a link to a post on Bluesky and hoping it embeds, but if it doesn't, it can feel a tad opaque when it comes to figuring out what went wrong.</p>
<p>To rectify this, you can <a href="https://site-validator.fly.dev/">make use of Standard.site Validator</a> by <a href="https://octet-stream.net/">Thomas Karpiniec</a>. Consider returning to <a href="https://atproto.at/">Taproot</a> or <a href="https://pdsls.dev/">PDSLs</a> to study and review your records. Both will let you know if anything fails to validate and where things went awry.</p>
<h2>Further reading</h2>
<p>For some further reading, Piccalilli's own <a href="https://wil.to/">Mat Marquis</a>, creator of the <a href="https://piccalil.li/javascript-for-everyone">JavaScript for Everyone</a> course, has written his own posts documenting his <a href="https://wil.to/posts/standard-site/">understanding of</a> and <a href="https://wil.to/posts/implementing-standard-site/">implementation of</a> Standard.site on his own blog.</p>
<p>If you want to have multiple Standard.site publications under a single domain, then Jason Lengstorf has <a href="https://codetv.dev/blog/multiple-standard-site-publications-on-one-website">an in-depth guide on the Code.TV blog</a>.</p>
        
        ]]></description>
        
      </item>
    
    </channel>
  </rss>
