Added support for <model src> and honor <source type> attributes (257518@main)
Anytime I see mention of some element I don’t recognize, my mind goes straight to Huh! New to me, but probably old news for everyone else. It’s poor posture, I know, as it could just as easily be:
Hmm, looks like some propriatary experiment.
Wow, a truly new thing!
Truth is, it’s sorta all three.
It’s an evolving concept
As in, the first somewhat official-sounding thing I found on <model> wasn’t in the W3C spec but in WebKit’s repo for explainers. All that’s in the README is a giant note from 2021 that “The <model> element has moved to the Immersive Web CG.” I was about to hop over but my eye caught the HistoryAndEvolution.md file which has a nice rundown of early context on the <model> concept:
The <model> element was born out of a desire to take the next step and improve the experience of Safari’s integration with iOS’s AR Quick Look feature.
I had to look at Apple’s splash page for AR Quick Look. You know the new feature that some stores have where you can transpose a 3D rendering of a product in your own home using your phone camera? That’s the sort of stuff we’re talking about, and Apple links up a nice case study from the Metropolitan Museum of Art.
As I understand it from this limited context:
Drop a <model> element in the document.
Add an external source file, e.g. <model src="assets/example.usdz">.
The original proposal is from the Immersive Web Committee Group
That’s the team looking make Virtual Reality (VR) and Augmented Reality (AR) part of the web. Apple linked up their repo, so I made the jump and went straight to the explainer. This isn’t the spec or anything, but the original proposal. A much better definition of the element!
HTML allows the display of many media types through elements such as <img>, <picture>, or <video>, but it does not provide a declarative manner to directly display 3D content. Embedding 3D content within a page is comparatively cumbersome and relies on scripting the <canvas> element. We believe it is time to put 3D models on equal footing with other, already supported, media types.
[…]
The HTML <model> element aims to allow a website to embed interactive 3D models as conveniently as any other visual media. Models are expected to be created by 3D authoring tools or generated dynamically, but served as a standalone resource by the server.
The basic example pulls this together. It really does feel like the <video> or <picture> elements:
.usdz? .glb? Not the type of files that typically cross my desk. Guess I’ll need to brush up on those and any other file types that <model> might support. Again, all of this is merely the original proposal.
There’s a lot to figure out. Most of what’s there are documented issues that need addressing. It does, however, shed more light on <model> like proposed attributes that make it feel even more like <video> such as autoplay, controls, loop, muted, poster, etc.
It goes back even further
The very earliest mention of 3D modeling I found was Keith Clark’s 2018 post in which he prototypes a custom element called <x-model>. He describes it as “a placeholder that provides access to the DOM and CSSOM” where the loading and rendering is done in three.js.
I mean, the draft spec hasn’t been fleshed out. Apple seems willing to play ball thanks to the Safari TP 161 announcement. That makes total sense given how bullish Apple is on AR as a whole. (Apple Glasses, anyone?)
Google seems to have its foot in the door, albeit on the Web Components side of things. It’s easy to see how there may be a conflict of interest between what Apple and Google want from AR on the web.
These are all just my notes from trying to grok everything. There’s gotta be a lot more nuance to it than what little I know about it so far. I’m sure someone smarter can tie neater bow around <model> in the comments. 😉
HTML lists are boring. They don’t do much, so we don’t really think about them despite how widely used they are. And we’re still able to do the same things we’ve always done to customize them, like removing markers, reversing order, and making custom counters.
There are, however, a few “newer” things — including dangers — to know when using lists. The dangers are mostly minor, but way more common than you might think. We’ll get to those, plus some new stuff we can do with lists, and even new ways to approach old solutions.
To clarify, these are the HTML elements we’re talking about:
Ordered lists <ol>
Unordered lists <ul>
Description lists <dl>
Interactive lists <menu>
Ordered lists, unordered lists, and interactive lists contain list items (<li>) which are displayed according to what kind of list we’re dealing with. An ordered list (<ol>) displays numbers next to list items. Unordered lists (<ul>) and menu elements (<menu>) displays bullet points next to list items. We call these “list markers” and they can even be styled using the ::marker pseudo-element. Description lists use description terms (<dt>) and description details (<dd>) instead of <li> and don’t have list markers. They‘re supposed to be used to display metadata and glossaries, but I can’t say I’ve ever seen them in the wild.
Let’s start off with the easy stuff — how to correctly (at least in my opinion) reset list styles. After that, we’ll take a look at a couple of accessibility issues before shining a light on the elusive <menu> element, which you may be surprised to learn… is actually a type of list, too!
Resetting list styles
Browsers automatically apply their own User Agent styles to help with the visual structure of lists right out of the box. That can be great! But if we want to start with a blank slate free of styling opinions, then we have to reset those styles first.
For example, we can remove the markers next to list items pretty easily. Nothing new here:
/* Zap all list markers! */ ol, ul, menu { list-style: none; }
But modern CSS has new ways to help us target specific list instances. Let’s say we want to clear markers from all lists, except if those lists appear in long-form content, like an article. If we combine the powers of newer CSS pseudo-class functions :where() and :not(), we can isolate those instances and allow the markers in those cases:
/* Where there are lists that are not articles where there are lists... */ :where(ol, ul, menu):not(article :where(ol, ul, menu)) { list-style: none; }
Why use :where() instead of :is()? The specificity of :where() is always zero, whereas :is() takes the specificity of the most specific element in its list of selectors. So, using :where() is a less forceful way of overriding things and can be easily overridden itself.
UA styles also apply padding to space a list item’s content from its marker. Again, that’s a pretty nice affordance right out of the box in some cases, but if we’re already removing the list markers like we did above, then we may as well wipe out that padding too. This is another case for :where():
OK, that’s going to prevent marker-less list items from appearing to float in space. But we sort of tossed out the baby with the bathwater and removed the padding in all instances, including the ones we previously isolated in an <article>. So, now those lists with markers sorta hang off the edge of the content box.
Notice that UA styles apply an extra 40px to the <menu> element.
So what we want to do is prevent the list markers from “hanging” outside the container. We can fix that with the list-style-positionproperty:
Or not… maybe it comes down to stylistic preference?
Newer accessibility concerns with lists
Unfortunately, there are a couple of accessibility concerns when it comes to lists — even in these more modern times. One concern is a result of applying list-style: none; as we did when resetting UA styles.
In a nutshell, Safari does not read ordered and unordered lists styled with list-style: none as actual lists, like when navigating content with a screen reader. In other words, removing the markers also removes the list’s semantic meaning. The fix for this fix it to apply an ARIA list role on the list and a listitem role to the list items so screen readers will pick them up:
Oddly, Safari considers this to be a feature rather than a bug. Basically, users would report that screen readers were announcing too many lists (because developers tend to overuse them), so now, only those with role="list" are announced by screen readers, which actually isn’t that odd after all. Scott O’Hara has a detailed rundown of how it all went down.
A second accessibility concern isn’t one of our own making (hooray!). So, you know how you’re supposed to add an aria-label to <section> elements without headings? Well, it sometimes makes sense to do the same with a list that doesn’t contain a heading element that helps describe the list.
<!-- This list is somewhat described by the heading --> <section> <h2>Grocery list</h2> <ol role="list"> <!-- ... --> </ol> </section> <!-- This list is described by the aria-label --> <ol role="list" aria-label="Grocery list"> <!-- ... --> </ol>
You absolutely don’t have to use either method. Using a heading or an ARIA label is just added context, not a requirement — be sure to test your websites with screen readers and do what offers the best user experience for the situation.
OK, so, you’re likely wondering about all of the <menu> elements that I’ve been slipping into the code examples. It’s actually super simple; menus are unordered lists except that they’re meant for interactive items. They’re even exposed to the accessibility tree as unordered lists.
In the early days of the semantic web, I mistakenly believed that menus were like <nav>s before believing that they were for context menus (or “toolbars” as the spec says) because that’s what early versions of the HTML spec said. (MDN has an interesting write-up on all of the deprecated stuff related to <menu> if you’re at all interested.)
Today, however, this is the semantic way to use menus:
Personally, I think there are some good use-cases for <menu>. That last example shows a list of social sharing buttons wrapped up in a labeled <menu> element, the notable aspect being that the “Share article” label contributes a significant amount of context that helps describe what the buttons do.
Are menus absolutely necessary? No. Are they HTML landmarks? Definitely not. But they’re there if you enjoy fewer <div>s and you feel like the component could use an aria-label for additional context.
Anything else?
Yes, there’s also the aforementioned <dl> (description list) element, however, MDN doesn’t seem to consider them lists in the same way — it’s a list of groups containing terms — and I can’t say that I’ve really seen them in use. According to MDN, they’re supposed to be used for metadata, glossaries, and other types of key-value pairs. I would just avoid them on the grounds that all screen readers announce them differently.
But let’s not end things on a negative note. Here’s a list of super cool things you can do with lists:
Earlier this year, I self-published an ebook called Understanding JavaScript Promises (free for download). Even though I didn’t have any intention of turning it into a print book, enough people reached out inquiring about a print version that I decided to self-publish that as well .I thought it would be an easy exercise using HTML and CSS to generate a PDF and then send it off to the printer. What I didn’t realize was that I didn’t have an answer to an important part of a print book: the table of contents.
The makeup of a table of contents
At its core, a table of contents is fairly simple. Each line represents a part of a book or webpage and indicates where you can find that content. Typically, the lines contain three parts:
The title of the chapter or section
Leaders (i.e. those dots, dashes, or lines) that visually connect the title to the page number
The page number
A table of contents is easy to generate inside of word processing tools like Microsoft Word or Google Docs, but because my content was in Markdown and then transformed into HTML, that wasn’t a good option for me. I wanted something automated that would work with HTML to generate the table of contents in a format that was suitable for print. I also wanted each line to be a link so it could be used in webpages and PDFs to navigate around the document. I also wanted dot leaders between the title and page number.
And so I began researching.
I came across two excellent blog posts on creating a table of contents with HTML and CSS. The first was “Build a Table of Contents from your HTML” by Julie Blanc. Julie worked on PagedJS, a polyfill for missing paged media features in web browsers that properly formats documents for print. I started with Julie’s example, but found that it didn’t quite work for me. Next, I found Christoph Grabo’s “Responsive TOC leader lines with CSS” post, which introduced the concept of using CSS Grid (as opposed to Julie’s float-based approach) to make alignment easier. Once again, though, his approach wasn’t quite right for my purposes.
After reading these two posts, though, I felt I had a good enough understanding of the layout issues to embark on my own. I used pieces from both blog posts as well as adding some new HTML and CSS concepts into the approach to come up with a result I’m happy with.
Choosing the correct markup
When deciding on the correct markup for a table of contents, I thought primarily about the correct semantics. Fundamentally, a table of contents is about a title (chapter or subsection) being tied to a page number, almost like a key-value pair. That led me to two options:
One option is to use a table (<table>) with one column for the title and one column for the page.
Then there’s the often unused and forgotten definition list (<dl>) element. It also acts as a key-value map. So, once again, the relationship between the title and the page number would be obvious.
Either of these seemed like good options until I realized that they really only work for single-level tables of contents, namely, only if I wanted to have a table of contents with just chapter names. If I wanted to show subsections in the table of contents, though, I didn’t have any good options. Table elements aren’t great for hierarchical data, and while definition lists can technically be nested, the semantics didn’t seem correct. So, I went back to the drawing board.
I decided to build off of Julie’s approach and use a list; however, I opted for an ordered list (<ol>) instead of an unordered list (<ul>). I think an ordered list is more appropriate in this case. A table of contents represents a list of chapters and subheadings in the order in which they appear in the content. The order matters and shouldn’t get lost in the markup.
Unfortunately, using an ordered list means losing the semantic relationship between the title and the page number, so my next step was to re-establish that relationship within each list item. The easiest way to solve this is to simply insert the word “page” before the page number. That way, the relationship of the number relative to the text is clear, even without any other visual distinction.
Here’s a simple HTML skeleton that formed the basis of my markup:
Once I had established the markup I planned to use, the next step was to apply some styles.
First, I removed the autogenerated numbers. You can choose to keep the autogenerated numbers in your own project if you’d like, but it’s common for books to have unnumbered forewords and afterwords included in the list of chapters, which makes the autogenerated numbers incorrect.
For my purpose, I would fill in the chapter numbers manually then adjust the layout so the top-level list doesn’t have any padding (thus aligning it with paragraphs) and each embedded list is indented by two spaces. I chose to use a 2ch padding value because I still wasn’t quite sure which font I would use. The ch length unit allows the padding to be relative to the width of a character — no matter what font is used — rather than an absolute pixel size that could wind up looking inconsistent.
Here’s the CSS I ended up with:
.toc-list, .toc-list ol { list-style-type: none; } .toc-list { padding: 0; } .toc-list ol { padding-inline-start: 2ch; }
Sara Soueidan pointed out to me that WebKit browsers remove list semantics when list-style-type is none, so I needed to add role="list" into the HTML to preserve it:
With the list styled to my liking, it was time to move on to styling an individual list item. For each item in the table of contents, the title and page number must be on the same line, with the title to the left and the page number aligned to the right.
You might be thinking, “No problem, that’s what flexbox is for!” You aren’t wrong! Flexbox can indeed achieve the correct title-page alignment. But there are some tricky alignment issues when the leaders are added, so I instead opted to go with Christoph’s approach using a grid, which as a bonus as it also helps with multiline titles. Here is the CSS for an individual item:
.toc-list li > a { text-decoration: none; display: grid; grid-template-columns: auto max-content; align-items: end; } .toc-list li > a > .page { text-align: right; }
The grid has two columns, the first of which is auto-sized to fill up the entire width of the container, minus the second column, which is sized to max-content. The page number is aligned to the right, as is traditional in a table of contents.
The only other change I made at this point was to hide the “Page” text. This is helpful for screen readers but unnecessary visually, so I used a traditional visually-hidden class to hide it from view:
With this foundation in place, I moved on to address the leaders between the title and the page.
Creating dot leaders
Leaders are so common in print media that you might be wondering, why doesn’t CSS already support that? The answer is: it does. Well, kind of.
There is actually a leader() function defined in the CSS Generated Content for Paged Media specification. However, as with much of the paged media specifications, this function isn’t implemented in any browsers, therefore excluding it as an option (at least at the time I’m writing this). It’s not even listed on caniuse.com, presumably because no one has implemented it and there are no plans or signals that they will.
Fortunately, both Julie and Christoph already addressed this problem in their respective posts. To insert the dot leaders, they both used a ::after pseudo-element with its content property set to a very long string of dots, like this:
The ::after pseudo-element is set to an absolute position to take it out of the flow of the page and avoid wrapping to other lines. The text is aligned to the right because we want the last dots of each line flush to the number at the end of the line. (More on the complexities of this later.) The .title element is set to have a relative position so the ::after pseudo-element doesn’t break out of its box. Meanwhile, the overflow is hidden so all those extra dots invisible. The result is a pretty table of contents with dot leaders.
However, there’s something else that needs consideration.
Sara also pointed out to me that all of those dots count as text to screen readers. So what do you hear? “Introduction dot dot dot dot…” until all of the dots are announced. That’s an awful experience for screen reader users.
The solution is to insert an additional element with aria-hidden set to true and then use that element to insert the dots. So the HTML becomes:
Now screen readers will ignore the dots and spare users the frustration of listening to multiple dots being announced.
Finishing touches
At this point, the table of contents component looks pretty good, but it could use some minor detail work. To start, most books visually offset chapter titles from subsection titles, so I made the top-level items bold and introduced a margin to separate subsections from the chapters that followed:
.toc-list > li > a { font-weight: bold; margin-block-start: 1em; }
Next, I wanted to clean up the alignment of the page numbers. Everything looked okay when I was using a fixed-width font, but for variable-width fonts, the leader dots could end up forming a zigzag pattern as they adjust to the width of a page number. For instance, any page number with a 1 would be narrower than others, resulting in leader dots that are misaligned with the dots on previous or following lines.
To fix this problem, I set font-variant-numeric to tabular-nums so all numbers are treated with the same width. By also setting the minimum width to 2ch, I ensured that all numbers with one or two digits are perfectly aligned. (You may want to set this to 3ch if your project has more than 100 pages.) Here is the final CSS for the page number:
.toc-list li > a > .page { min-width: 2ch; font-variant-numeric: tabular-nums; text-align: right; }
And with that, the table of contents is complete!
Conclusion
Creating a table of contents with nothing but HTML and CSS was more of a challenge than I expected, but I’m very happy with the result. Not only is this approach flexible enough to accommodate chapters and subsections, but it handles sub-subsections nicely without updating the CSS. The overall approach works on web pages where you want to link to the various locations of content, as well as PDFs where you want the table of contents to link to different pages. And of course, it also looks great in print if you’re ever inclined to use it in a brochure or book.
I’d like to thank Julie Blanc and Christoph Grabo for their excellent blog posts on creating a table of contents, as both of those were invaluable when I was getting started. I’d also like to thank Sara Soueidan for her accessibility feedback as I worked on this project.
As the author of a library called AgnosticUI, I’m always on the lookout for new components. And recently, I decided to dig in and start work on a new dialog (aka modal) component. That’s something many devs like to have in their toolset and my goal was to make the best one possible, with an extra special focus on making it inclusive and accessible.
My first thought was that I would avoid any dependencies and bite the bullet to build my own dialog component. As you may know, there’s a new <dialog> element making the rounds and I figured using it as a starting point would be the right thing, especially in the inclusiveness and accessibilities departments.
But, after doing some research, I instead elected to leverage a11y-dialog by Kitty Giraudel. I even wrote adapters so it integrates smoothly with Vue 3, Svelte, and Angular. Kitty has long offered a React adapter as well.
Why did I go that route? Let me take you through my thought process.
First question: Should I even use the native <dialog> element?
The native <dialog> element is being actively improved and will likely be the way forward. But, it still has some issues at the moment that Kitty pointed out quite well:
Clicking the backdrop overlay does not close the dialog by default
The alertdialog ARIA role used for alerts simply does not work with the native <dialog> element. We’re supposed to use that role when a dialog requires a user’s response and shouldn’t be closed by clicking the backdrop, or by pressing ESC.
And as Kitty also points out, there are general issues with the element’s default styles, like the fact they are left to the browser and will require JavaScript. So, it’s sort of not 100% HTML anyway.
Here’s a pen demonstrating these points:
Now, some of these issues may not affect you or whatever project you’re working on specifically, and you may even be able to work around things. If you still would like to utilize the native dialog you should see Adam Argyle’s wonderful post on building a dialog component with native dialog.
OK, let’s discuss what actually are the requirements for an accessible dialog component…
What I’m looking for
I know there are lots of ideas about what a dialog component should or should not do. But as far as what I was personally going after for AgnosticUI hinged on what I believe make for an accessible dialog experience:
The dialog should close when clicking outside the dialog (on the backdrop) or when pressing the ESC key.
It should trap focus to prevent tabbing out of the component with a keyboard.
It should allow forwarding tabbing with TAB and backward tabbing with SHIFT+TAB.
It should return focus back to the previously focused element when closed.
It should correctly apply aria-* attributes and toggles.
It should provide Portals (only if we’re using it within a JavaScript framework).
It should support the alertdialog ARIA role for alert situations.
It would be great if our implementation could avoid the common pitfalls that come with the native <dialog> element.
It would ideally provide a way to apply custom styling while also taking the prefers-reduced-motion user preference query as a further accessibility measure.
It should be clear right about now why I nixed the native <dialog> element from my component library. I believe in the work going into it, of course, but my current needs simply outweigh the costs of it. That’s why I went with Kitty’s a11y-dialog as my starting point.
Auditing <dialog> accessibility
Before trusting any particular dialog implementation, it’s worth making sure it fits the bill as far as your requirements go. With my requirements so heavily leaning on accessibility, that meant auditing a11y-dialog.
Accessibility audits are a profession of their own. And even if it’s not my everyday primary focus, I know there are some things that are worth doing, like:
manually verifying the functionality listed above, of course in different browsers,
This is quite a lot of work, as you might imagine (or know from experience). It’s tempting to take a path of less resistance and try automating things but, in a study conducted by Deque Systems, automated tooling can only catch about 57% of accessibility issues. There’s no substitute for good ol’ fashioned hard work.
The auditing environment
The dialog component can be tested in lots of places, including Storybook, CodePen, CodeSandbox, or whatever. For this particular test, though, I prefer instead to make a skeleton page and test locally. This way I’m preventing myself from having to validate the validators, so to speak. Having to use, say, a Storybook-specific add-on for a11y verification is fine if you’re already using Storybook on your own components, but it adds another layer of complexity when testing the accessibility of an external component.
A skeleton page can verify the dialog with manual checks, existing a11y tooling, and screen readers. If you’re following along, you’ll want to run this page via a local server. There are many ways to do that; one is to use a tool called serve, and npm even provides a nice one-liner npx serve <DIRECTORY> command to fire things up.
Let’s do an example audit together!
I’m obviously bullish on a11y-dialog here, so let’s put it to the test and verify it using some of the the recommended approaches we’ve covered.
Again, all I’m doing here is starting with an HTML. You can use the same one I am (complete with styles and scripts baked right in).
I know, we’re ignoring a bunch of best practices (what, styles in the <head>?!) and combined all of the HTML, CSS, and JavaScript in one file. I won’t go into the details of the code as the focus here is testing for accessibility, but know that this test requires an internet connection as we are importing a11y-dialog from a CDN.
First, the manual checks
I served this one-pager locally and here are my manual check results:
Feature
Result
It should close when clicking outside the dialog (on the backdrop) or when pressing the ESC key.
✅
It ought to trap focus to prevent tabbing out of the component with a keyboard.
✅
It should allow forwarding tabbing with TAB and backward tabbing with SHIFT+TAB.
✅
It should return focus back to the previously focused element when closed.
✅
It should correctly apply aria-* attributes and toggles.
✅ I verified this one “by eye” after inspecting the elements in the DevTools Elements panel.
It should provide Portals.
Not applicable. This is only useful when implementing the element with React, Svelte, Vue, etc. We’ve statically placed it on the page with aria-hidden for this test.
It should support for the alertdialog ARIA role for alert situations.
✅ You’ll need to do two things:
First, remove data-a11y-dialog-hide from the overlay in the HTML so that it is <div class="dialog-overlay"></div>. Replace the dialog role with alertdialog so that it becomes:
Now, clicking on the overlay outside of the dialog box does not close the dialog, as expected.
It should prevent the underlying bodyfrom scrolling, if needed.
✅ I didn’t manually test but this, but it is clearly available per the documentation.
It should avoid the common pitfalls that come with the native <dialog> element.
✅ This component does not rely on the native <dialog> which means we’re good here.
Next, let’s use some a11y tooling
I used Lighthouse to test the component both on a desktop computer and a mobile device, in two different scenarios where the dialog is open by default, and closed by default.
I’ve found that sometimes the tooling doesn’t account for DOM elements that are dynamically shown or hidden DOM elements, so this test ensures I’m getting full coverage of both scenarios.
I also tested with IBM Equal Access Accessibility Checker. Generally, this tool will give you a red violation error if there’s anything egregious wrong. It will also ask you to manually review certain items. As seen here, there a couple of items for manual review, but no red violations.
Moving on to screen readers
Between my manual and tooling checks, I’m already feeling relatively confident that a11y-dialog is an accessible option for my dialog of choice. However, we ought to do our due diligence and consult a screen reader.
VoiceOver is the most convenient screen reader for me since I work on a Mac at the moment, but JAWS and NVDA are big names to look at as well. Like checking for UI consistency across browsers, it’s probably a good idea to test on more than one screen reader if you can.
Here’s how I conducted the screen reader part of the audit with VoiceOver. Basically, I mapped out what actions needed testing and confirmed each one, like a script:
Step
Result
The dialog component’s trigger button is announced.
“Entering A11y Dialog Test, web content.”
The dialog should open when pressing CTRL+ALT +Space should show the dialog.
“Dialog. Some description of what’s inside this dialog. You are currently on a dialog, inside of web content.”
The dialog should TAB to and put focus on the component’s Close button.
“Close this dialog button. You are currently on a button, inside of web content.”
Tab to the link element and confirm it is announced.
“Link, Rando Yahoo Link”
Pressing the SPACE key while focused on the Close button should close the dialog component and return to the last item in focus.
✅
Testing with people
If you’re thinking we’re about to move on to testing with real people, I was unfortunately unable to find someone. If I had done this, though, I would have used a similar set of steps for them to run through while I observe, take notes, and ask a few questions about the general experience.
As you can see, a satisfactory audit involves a good deal of time and thought.
Fine, but I want to use a framework’s dialog component
That’s cool! Many frameworks have their own dialog component solution, so there’s lots to choose from. I don’t have some amazing spreadsheet audit of all the frameworks and libraries in the wild, and will spare you the work of evaluating them all.
Instead, here are some resources that might be good starting points and considerations for using a dialog component in some of the most widely used frameworks.
Disclaimer: I have not tested these personally. This is all stuff I found while researching.
You might want to look at svelte-headlessui. Material has a port in svelterial that is also worth a look. It seems that many current SvelteKit users prefer to build their own component sets as SvelteKit’s packaging idiom makes it super simple to do. If this is you, I would definitely recommend considering svelte-a11y-dialog as a convenient means to build custom dialogs, drawers, bottom sheets, etc.
I’ll also point out that my AgnosticUI library wraps the React, Vue, Svelte and Angular a11y-dialog adapter implementations we’ve been talking about earlier.
If you have other inclusive and accessible library-based dialog components that merit consideration, I’d love to know about them in the comments!
But I’m creating a custom design system
If you’re creating a design system or considering some other roll-your-own dialog approach, you can see just how many things need to be tested and taken into consideration… all for one component! It’s certainly doable to roll your own, of course, but I’d say it’s also extremely prone to error. You might ask yourself whether the effort is worthwhile when there are already battle-tested options to choose from.
You could put in the effort to add in those extensions, or you could use a robust plugin like a11y-dialog and ensure that your dialogs will have a pretty consistent experience across all browsers.
Back to my objective…
I need that dialog to support React, Vue, Svelte, and Angular implementations.
I mentioned earlier that a11y-dialog already has ports for Vue and React. But the Vue port hasn’t yet been updated for Vue 3. Well, I was quite happy to spend the time I would have spent creating what likely would have been a buggy hand-rolled dialog component toward helping update the Vue port. I also added a Svelte port and one for Angular too. These are both very new and I would consider them experimental beta software at time of writing. Feedback welcome, of course!
It can support other components, too!
I think it’s worth pointing out that a dialog uses the same underlying concept for hiding and showing that can be used for a drawer (aka off-canvas) component. For example, if we borrow the CSS we used in our dialog accessibility audit and add a few additional classes, then a11y-dialog can be transformed into a working and effective drawer component:
These classes are used in an additive manner, essentially extending the base dialog component. This is exactly what I have started to do as I add my own drawer component to AgnosticUI. Saving time and reusing code FTW!
Wrapping up
Hopefully I’ve given you a good idea of the thinking process that goes into the making and maintenance of a component library. Could I have hand-rolled my own dialog component for the library? Absolutely! But I doubt it would have yielded better results than what a resource like Kitty’s a11y-dialog does, and the effort is daunting. There’s something cool about coming up with your own solution — and there may be good situations where you want to do that — but probably not at the cost of sacrificing something like accessibility.
Anyway, that’s how I arrived at my decision. I learned a lot about the native HTML <dialog> and its accessibility along the way, and I hope my journey gave you some of those nuggets too.
You may not use XHTML (anymore), but when you write HTML, you may be more influenced by XHTML than you think. You are very likely writing HTML, the XHTML way.
What is the XHTML way of writing HTML, and what is the HTML way of writing HTML? Let’s have a look.
HTML, XHTML, HTML
In the 1990s, there was HTML. In the 2000s, there was XHTML. Then, in the 2010s, we switched back to HTML. That’s the simple story.
You can tell by the rough dates of the specifications, too: HTML “1” 1992, HTML 2.0 1995, HTML 3.2 1997, HTML 4.01 1999; XHTML 1.0 2000, XHTML 1.1 2001; “HTML5” 2007.
XHTML became popular when everyone believed XML and XML derivatives were the future. “XML all the things.” For HTML, this had a profound effect: The effect that we learned to write it the XHTML way.
The XHTML way of writing HTML
The XHTML way is well-documented, because XHTML 1.0 describes in great detail in its section on “Differences with HTML 4”:
Documents must be well-formed.
Element and attribute names must be in lower case.
White space handling in attribute values is done according to XML.
Script and style elements need CDATA sections.
SGML exclusions are not possible.
The elements with id and name attributes, like a, applet, form, frame, iframe, img, and map, should only use id.
Attributes with pre-defined value sets are case-sensitive.
Entity references as hex values must be in lowercase.
Does this look familiar? With the exception of marking CDATA content, as well as dealing with SGML exclusions, you probably follow all of these rules. All of them.
Although XHTML is dead, many of these rules have never been questioned again. Some have even been elevated to “best practices” for HTML.
That is the XHTML way of writing HTML, and its lasting impact on the field.
The HTML way of writing HTML
One way of walking us back is to negate the rules imposed by XHTML. Let’s actually do this (without the SGML part, because HTML isn’t based on SGML anymore):
Documents may not be well-formed.
Element and attribute names may not be in lower case.
For non-empty elements, end tags are not always required.
Attribute values may not always be quoted.
Attribute minimization is supported.
Empty elements don’t need to be closed.
White space handling in attribute values isn’t done according to XML.
Script and style elements don’t need CDATA sections.
The elements with id and name attributes may not only use id.
Attributes with pre-defined value sets are not case-sensitive.
Entity references as hex values may not only be in lowercase.
Let’s remove the esoteric things; the things that don’t seem relevant. This includes XML whitespace handling, CDATA sections, doubling of name attribute values, the case of pre-defined value sets, and hexadecimal entity references:
Documents may not be well-formed.
Element and attribute names may not be in lowercase.
For non-empty elements, end tags are not always required.
Attribute values may not always be quoted.
Attribute minimization is supported.
Empty elements don’t need to be closed.
Peeling away from these rules, this looks a lot less like we’re working with XML, and more like working with HTML. But we’re not done yet.
“Documents may not be well-formed” suggests that it was fine if HTML code was invalid. It was fine for XHTML to point to wellformedness because of XML’s strict error handling. But while HTML documents work even when they contain severe syntax and wellformedness issues, it’s neither useful for the professional — nor our field — to use and abuse this resilience. (I’ve argued this case before in my article, “In Critical Defense of Frontend Development.”)
The HTML way would therefore not suggest “documents may not be well-formed.” It would also be clear that not only end, but also start tags aren’t always required. Rephrasing and reordering, this is the essence:
Start and end tags are not always required.
Empty elements don’t need to be closed.
Element and attribute names may be lower or upper case.
Attribute values may not always be quoted.
Attribute minimization is supported.
Examples
How does this look like in practice? For start and end tags, be aware that many tags are optional. A paragraph and a list, for example, are written like this in XHTML:
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p> <ul> <li>Praesent augue nisl</li> <li>Lobortis nec bibendum ut</li> <li>Dictum ac quam</li> </ul>
In HTML, however, you can write them using only this code (which is valid):
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <ul> <li>Praesent augue nisl <li>Lobortis nec bibendum ut <li>Dictum ac quam </ul>
Developers also learned to write void elements, like so:
As a rule of thumb, when the attribute value doesn’t contain a space or an equal sign, it’s usually fine to drop the quotes.
Finally, HTML–HTML — not XHTML–HTML — also allows to minimize attributes. That is, instead of marking an input element as required and read-only, like this:
If you’re not only taking advantage of the fact that the quotes aren’t needed, but that text is the default for the type attribute here (there are more such unneeded attribute–value combinations), you get an example that shows HTML in all its minimal beauty:
<input required readonly>
Write HTML, the HTML way
The above isn’t a representation of where HTML was in the 90s. HTML, back then, was loaded with <table> elements for layout, packed with presentational code, largely invalid (as it’s still today), with wildly varying user agent support. Yet it’s the essence of what we would have wanted to keep if XML and XHTML hadn’t come around.
If you’re open to a suggestion of what a more comprehensive, contemporary way of writing HTML could look like, I have one. (HTML is my main focus area, so I’m augmenting this by links to some of my articles.)
You’ve learned HTML the XHTML way. HTML isn’t XHTML. Rediscover HTML, and help shape a new, modern way of writing HTML — which acknowledges, but isn’t necessarily based on XML.
You know how there are JavaScript dialogs for alerting, confirming, and prompting user actions? Say you want to replace JavaScript dialogs with the new HTML dialog element.
Let me explain.
I recently worked on a project with a lot of API calls and user feedback gathered with JavaScript dialogs. While I was waiting for another developer to code the <Modal /> component, I used alert(), confirm() and prompt() in my code. For instance:
Then it hit me: you get a lot of modal-related features for free with alert(), confirm(), and prompt() that often go overlooked:
It’s a true modal. As in, it will always be on top of the stack — even on top of that <div> with z-index: 99999;.
It’s accessible with the keyboard. Press Enter to accept and Escape to cancel.
It’s screen reader-friendly. It moves focus and allows the modal content to be read aloud.
It traps focus. Pressing Tab will not reach any focusable elements on the main page, but in Firefox and Safari it does indeed move focus to the browser UI. What’s weird though is that you can’t move focus to the “accept” or “cancel” buttons in any browser using the Tab key.
It supports user preferences. We get automatic light and dark mode support right out of the box.
It pauses code-execution., Plus, it waits for user input.
These three JavaScripts methods work 99% of the time when I need any of these functionalities. So why don’t I — or really any other web developer — use them? Probably because they look like system errors that cannot be styled. Another big consideration: there has been movement toward their deprecation. First removal from cross-domain iframes and, word is, from the web platform entirely, although it also sounds like plans for that are on hold.
With that big consideration in mind, what are alert(), confirm() and prompt() alternatives do we have to replace them? You may have already heard about the <dialog> HTML element and that’s what I want to look at in this article, using it alongside a JavaScript class.
It’s impossible to completely replace Javascript dialogs with identical functionality, but if we use the showModal() method of <dialog> combined with a Promise that can either resolve (accept) or reject (cancel) — then we have something almost as good. Heck, while we’re at it, let’s add sound to the HTML dialog element — just like real system dialogs!
If you’d like to see the demo right away, it’s here.
A dialog class
First, we need a basic JavaScript Class with a settings object that will be merged with the default settings. These settings will be used for all dialogs, unless you overwrite them when invoking them (but more on that later).
The road for browsers to support <dialog> has been long. Safari picked it up pretty recently. Firefox even more recently, though not the <form method="dialog"> part. So, we need to add type="button" to the “Accept” and “Cancel” buttons we’re mimicking. Otherwise, they’ll POST the form and cause a page refresh and we want to avoid that.
So far, this.elements.accept is a reference to the “Accept” button, and this.elements.cancel refers to the “Cancel” button.
Button attributes
For screen readers, we need an aria-labelledby attribute pointing to the ID of the tag that describes the dialog — that’s the <legend> tag and it will contain the message.
Good news! The HTML dialog element has a built-in cancel() method making it easier to replace JavaScript dialogs calling the confirm() method. Let’s emit that event when we click the “Cancel” button:
That’s the framework for our <dialog> to replace alert(), confirm(), and prompt().
Polyfilling unsupported browsers
We need to hide the HTML dialog element for browsers that do not support it. To do that, we’ll wrap the logic for showing and hiding the dialog in a new method, toggle():
toggle(open = false) { if (this.dialogSupported && open) this.dialog.showModal() if (!this.dialogSupported) { document.body.classList.toggle(this.settings.bodyClass, open) this.dialog.hidden = !open /* If a `target` exists, set focus on it when closing */ if (this.elements.target && !open) { this.elements.target.focus() } } } /* Then call it at the end of `init`: */ this.toggle()
Keyboard navigation
Next up, let’s implement a way to trap focus so that the user can tab between the buttons in the dialog without inadvertently exiting the dialog. There are many ways to do this. I like the CSS way, but unfortunately, it’s unreliable. Instead, let’s grab all focusable elements from the dialog as a NodeList and store it in this.focusable:
Next, we’ll add a keydown event listener, handling all our keyboard navigation logic:
this.dialog.addEventListener('keydown', e => { if (e.key === 'Enter') { if (!this.dialogSupported) e.preventDefault() this.elements.accept.dispatchEvent(new Event('click')) } if (e.key === 'Escape') this.dialog.dispatchEvent(new Event('cancel')) if (e.key === 'Tab') { e.preventDefault() const len = this.focusable.length - 1; let index = this.focusable.indexOf(e.target); index = e.shiftKey ? index-1 : index+1; if (index < 0) index = len; if (index > len) index = 0; this.focusable[index].focus(); } })
For Enter, we need to prevent the <form> from submitting in browsers where the <dialog> element is unsupported. Escape will emit a cancel event. Pressing the Tab key will find the current element in the node list of focusable elements, this.focusable, and set focus on the next item (or the previous one if you hold down the Shift key at the same time).
Displaying the <dialog>
Now let’s show the dialog! For this, we need a small method that merges an optional settings object with the default values. In this object — exactly like the default settings object — we can add or change the settings for a specific dialog.
open(settings = {}) { const dialog = Object.assign({}, this.settings, settings) this.dialog.className = dialog.dialogClass || '' /* set innerText of the elements */ this.elements.accept.innerText = dialog.accept this.elements.cancel.innerText = dialog.cancel this.elements.cancel.hidden = dialog.cancel === '' this.elements.message.innerText = dialog.message /* If sounds exists, update `src` */ this.elements.soundAccept.src = dialog.soundAccept || '' this.elements.soundOpen.src = dialog.soundOpen || '' /* A target can be added (from the element invoking the dialog */ this.elements.target = dialog.target || '' /* Optional HTML for custom dialogs */ this.elements.template.innerHTML = dialog.template || '' /* Grab focusable elements */ this.focusable = this.getFocusable() this.hasFormData = this.elements.fieldset.elements.length > 0 if (dialog.soundOpen) { this.elements.soundOpen.play() } this.toggle(true) if (this.hasFormData) { /* If form elements exist, focus on that first */ this.focusable[0].focus() this.focusable[0].select() } else { this.elements.accept.focus() } }
Phew! That was a lot of code. Now we can show the <dialog> element in all browsers. But we still need to mimic the functionality that waits for a user’s input after execution, like the native alert(), confirm(), and prompt() methods. For that, we need a Promise and a new method I’m calling waitForUser():
This method returns a Promise. Within that, we add event listeners for “cancel” and “accept” that either resolve false (cancel), or true (accept). If formData exists (for custom dialogs or prompt), these will be collected with a helper method, then returned in an object:
We can remove the event listeners immediately, using { once: true }.
To keep it simple, I don’t use reject() but rather simply resolve false.
Hiding the <dialog>
Earlier on, we added event listeners for the built-in cancel event. We call this event when the user clicks the “cancel” button or presses the Escape key. The cancel event removes the open attribute on the <dialog>, thus hiding it.
Where to :focus?
In our open() method, we focus on either the first focusable form field or the “Accept” button:
if (this.hasFormData) { this.focusable[0].focus() this.focusable[0].select() } else { this.elements.accept.focus() }
But is this correct? In the W3’s “Modal Dialog” example, this is indeed the case. In Scott Ohara’s example though, the focus is on the dialog itself — which makes sense if the screen reader should read the text we defined in the aria-labelledby attribute earlier. I’m not sure which is correct or best, but if we want to use Scott’s method. we need to add a tabindex="-1" to the <dialog> in our init method:
this.dialog.tabIndex = -1
Then, in the open() method, we’ll replace the focus code with this:
this.dialog.focus()
We can check the activeElement (the element that has focus) at any given time in DevTools by clicking the “eye” icon and typing document.activeElement in the console. Try tabbing around to see it update:
Clicking the “eye” icon
Adding alert, confirm, and prompt
We’re finally ready to add alert(), confirm() and prompt() to our Dialog class. These will be small helper methods that replace JavaScript dialogs and the original syntax of those methods. All of them call the open()method we created earlier, but with a settings object that matches the way we trigger the original methods.
Let’s compare with the original syntax.
alert() is normally triggered like this:
window.alert(message);
In our Dialog, we’ll add an alert() method that’ll mimic this:
We set cancel and template to empty strings, so that — even if we had set default values earlier — these will not be hidden, and only message and accept are shown.
confirm() is normally triggered like this:
window.confirm(message);
In our version, similar to alert(), we create a custom method that shows the message, cancel and accept items:
{ target: event.target } is a reference to the DOM element that calls the method. We’ll use that to refocus on that element when we close the <dialog>, returning the user to where they were before the dialog was fired.
We ought to test this
It’s time to test and make sure everything is working as expected. Let’s create a new HTML file, import the class, and create an instance:
<script type="module"> import Dialog from './dialog.js'; const dialog = new Dialog(); </script>
Try out the following use cases one at a time!
/* alert */ dialog.alert('Please refresh your browser') /* or */ dialog.alert('Please refresh your browser').then((res) => { console.log(res) }) /* confirm */ dialog.confirm('Do you want to continue?').then((res) => { console.log(res) }) /* prompt */ dialog.prompt('The meaning of life?', 42).then((res) => { console.log(res) })
Then watch the console as you click “Accept” or “Cancel.” Try again while pressing the Escape or Enter keys instead.
Async/Await
We can also use the async/await way of doing this. We’re replacing JavaScript dialogs even more by mimicking the original syntax, but it requires the wrapping function to be async, while the code within requires the await keyword:
document.getElementById('promptButton').addEventListener('click', async (e) => { const value = await dialog.prompt('The meaning of life?', 42); console.log(value); });
Cross-browser styling
We now have a fully-functional cross-browser and screen reader-friendly HTML dialog element that replaces JavaScript dialogs! We’ve covered a lot. But the styling could use a lot of love. Let’s utilize the existing data-component and data-ref-attributes to add cross-browser styling — no need for additional classes or other attributes!
What if the standard alert(), confirm() and prompt() methods we are mimicking won’t do the trick for your specific use case? We can actually do a bit more to make the <dialog> more flexible to cover more than the content, buttons, and functionality we’ve covered so far — and it’s not much more work.
Earlier, I teased the idea of adding a sound to the dialog. Let’s do that.
You can use the template property of the settings object to inject more HTML. Here’s a custom example, invoked from a <button> with id="btnCustom" that triggers a fun little sound from an MP3 file:
Here’s a Pen with everything we built! Open the console, click the buttons, and play around with the dialogs, clicking the buttons and using the keyboard to accept and cancel.
So, what do you think? Is this a good way to replace JavaScript dialogs with the newer HTML dialog element? Or have you tried doing it another way? Let me know in the comments!
Now your job is to rotate them. That is, cycle through classes on an HTML element. When some event occurs, if the element has state-1 on it, remove state-1 and add state-2. If it has state-2 on it, remove that and add state-3. On the last state, remove it, and cycle back to state-1.
It’s notable that we’re talking about 3+ classes here. The DOM has a .classList.toggle() function, even one that takes a conditional as a second parameter, but that’s primarily useful in a two-class on/off situation, not cycling through classes.
Why? There is a number of reasons. Changing a class name gives you lots of power to re-style things in the DOM, and state management like that is a cornerstone of modern web development. But to be specific, in my case, I was wanting to do FLIP animations where I’d change a layout and trigger a tween animation between the different states.
Careful about existing classes! I saw some ideas that overwrote .className, which isn’t friendly toward other classes that might be on the DOM element. All these are “safe” choices for cycling through classes in that way.
Because this is programming, there are lots of ways to get this done. Let’s cover a bunch of them — for fun. I tweeted about this issue, so many of these solutions are from people who chimed into that discussion.
A verbose if/else statement to cycle through classes
This is what I did at first to cycle through classes. That’s how my brain works. Just write out very specific instructions for exactly what you want to happen:
if (el.classList.contains("state-1")) { el.classList.remove("state-1"); el.classList.add("state-2"); } else if (el.classList.contains("state-2")) { el.classList.remove("state-2"); el.classList.add("state-3"); } else { el.classList.remove("state-3"); el.classList.add("state-1"); }
I don’t mind the verbosity here, because to me it’s super clear what’s going on and will be easy to return to this code and “reason about it,” as they say. You could consider the verbosity a problem — surely there is a way to cycle through classes with less code. But a bigger issue is that it isn’t very extensible. There is no semblance of configuration (e.g. change the names of the classes easily) or simple way to add classes to the party, or remove them.
RegEx off the old class, increment state, then re-add
This one comes from Tab Atkins. Since we know the format of the class, state-N, we can look for that, pluck off the number, use a little ternary to increment it (but not higher than the highest state), then add/remove the classes as a way of cycling through them:
A bunch of techniques to cycle through classes center around setting up an array of classes up front. This acts as configuration for cycling through classes, which I think is a smart way to do it. Once you have that, you can find the relevant classes for adding and removing them. This one is from Christopher Kirk-Nielsen:
Christopher had a nice idea for making the add/remove technique shorter as well. Turns out it’s the same…
el.classList.remove(classes[activeIndex]); el.classList.add(classes[nextIndex]); // Does the same thing. el.classList.replace(classes[activeIndex], classes[nextIndex]);
Mayank had a similar idea for cycling through classes by finding the class in an array, only rather than using classList.contains(), you check the classes currently on the DOM element with what is in the array.
const states = ["state-1", "state-2", "state-3"]; const current = [...el.classList].find(cls => states.includes(cls)); const next = states[(states.indexOf(current) + 1) % states.length]; el.classList.remove(current); el.classList.add(next);
Variations of this were the most common idea. Here’s Jhey’s and here’s Mike Wagz which sets up functions for moving forward and backward.
Cascading replace statements
Speaking of that replace API, Chris Calo had a clever idea where you chain them with the or operator and rely on the fact that it returns true/false if it works or doesn’t. So you do all three and one of them will work!
If you pre-configured a 1 upfront, you could cycle through classes 1-3 and add/remove them based on that. This is from Timothy Leverett who lists another similar option in the same tweet.
// Assumes a `let s = 1` upfront el.classList.remove(`state-$ {s + 1}`); s = (s + 1) % 3; el.classList.add(`state-$ {s + 1}`);
Use data-* attributes instead
Data attributes have the same specificity power, so I have no issue with this. They might actually be more clear in terms of state handling, but even better, they have a special API that makes them nice to manipulate. Munawwar Firoz had an idea that gets this down to a one-liner:
Give yourself a little abstraction, right? Many of the ideas wrote code this way, but so far I’ve move it out to focus on the idea itself. Here, I’ll leave the function in. This one is from Andrea Giammarchi in which a unique function for cycling through classes is set up ahead of time, then you call it as needed:
I heard from Kyle Simpson who had this same idea, almost character for character.
Others?
There were more ideas in the replies to my original tweet, but are, best I can tell, variations on what I’ve already shared above. Apologies if I missed yours! Feel free to share your idea again in the comments here. I see nobody used a switch statements — that could be a possibility!
David Desandro went as far as recording a video, which is wonderful as it slowly abstracts the concepts further and further until it’s succinct but still readable and much more flexible:
And here’s a demo Pen with all the code for each example in there. They are numbered, so to test out another one, comment out the one that is uncommented, and uncomment another example:
Lea Verou made a Web Component for processing Markdown. Looks like there were a couple of others out there already, but I agree with Lea in that this is a good use case for the light DOM (as opposed to the shadow DOM that is normally quite useful for web components), and that’s what Lea’s does. The output is HTML so I can imagine it’s ideal you can style it on the page like any other type rather than have to deal with that shadow DOM. I still feel like the styling stories for shadow DOM all kinda suck.
The story of how it came to be is funny and highly relatable. You just want to build one simple thing and it turns out you have to do 15 other things and it takes the better part of a week.
Three cheers for (draft stage) progress on a Sanitizer API! It’s gospel that you can’t trust user input. And indeed, any app I’ve ever worked on has dealt with bad actors trying to slip in and execute nefarious code somewhere it shouldn’t.
It’s the web developer’s job to clean user input before it is used again on the page (or stored, or used server-side). This is typically done with our own code or libraries that are pulled down to help. We might write a RegEx to strip anything that looks like HTML (or the like), which has the risk of bugs and those bad actors finding a way around what our code is doing.
Instead of user-land libraries or our dancing with it ourselves, we could let the browser do it:
// some function that turns a string into real nodes const untrusted_input = to_node("<em onclick='alert(1);'>Hello!</em>"); const sanitizer = new Sanitizer(); sanitizer.sanitize(untrusted_input); // <em>Hello!</em>
Then let it continue to be a browser responsibility over time. As the draft report says:
The browser has a fairly good idea of when it is going to execute code. We can improve upon the user-space libraries by teaching the browser how to render HTML from an arbitrary string in a safe manner, and do so in a way that is much more likely to be maintained and updated along with the browser’s own changing parser implementation.
This kind of thing is web standards at its best. Spot something annoying (and/or dangerous) that tons of people have to do, and step in to make it safer, faster, and better.
Jamstack has been in the website world for years. Static Site Generators (SSGs) — which often have content that lives right within a GitHub repo itself — are a big part of that story. That opens up the idea of having contributors that can open pull requests to add, change, or edit content. Very useful!
When we need to build content-based sites like this, it’s common to think about what database to use. Keeping content in a database is a time-honored good idea. But it’s not the only approach! SSGs can be a great alternative because…
They are cheap and easy to deploy. SSGs are usually free, making them great for an MVP or a proof of concept.
They have great security. There is nothing to hack through the browser, as all the site contains is often just static files.
You’re ready to scale. The host you’re already on can handle it.
There is another advantage for us when it comes to a content site. The content of the site itself can be written in static files right in the repo. That means that adding and updating content can happen right from pull requests on GitHub, for example. Even for the non-technically inclined, it opens the door to things like Netlify CMS and the concept of open authoring, allowing for community contributions.
But let’s go the super lo-fi route and embrace the idea of pull requests for content, using nothing more than basic HTML.
The challenge
How people contribute adding or updating a resource isn’t always perfectly straightforward. People need to understand how to fork your repository, how to and where to add their content, content formatting standards, required fields, and all sorts of stuff. They might even need to “spin up” the site themselves locally to ensure the content looks right.
People who seriously want to help our site sometimes will back off because the process of contributing is a technological hurdle and learning curve — which is sad.
You know what anybody can do? Use a <form>
Just like a normal website, the easy way for people to submit a content is to fill out a form and submit it with the content they want.
What if we can make a way for users to contribute content to our sites by way of nothing more than an HTML <form> designed to take exactly the content we need? But instead of the form posting to a database, it goes the route of a pull request against our static site generator? There is a trick!
The trick: Create a GitHub pull request with query parameters
Here’s a little known trick: We can pre-fill a pull request against our repository by adding query parameter to a special GitHub URL. This comes right from the GitHub docs themselves.
Let’s reverse engineer this.
If we know we can pre-fill a link, then we need to generate the link. We’re trying to make this easy remember. To generate this dynamic data-filled link, we’ll use a touch of JavaScript.
So now, how do we generate this link after the user submits the form?
Demo time!
Let’s take the Serverless site from CSS-Tricks as an example. Currently, the only way to add a new resource is by forking the repo on GitHub and adding a new Markdown file. But let’s see how we can do it with a form instead of jumping through those hoops.
The Serverless site itself has many categories (e.g. for forms) we can contribute to. For the sake of simplicity, let’s focus on the “Resources” category. People can add articles about things related to Serverless or Jamstack from there.
All of the resource files are in this folder in the repo.
Just picking a random file from there to explore the structure…
--- title: "How to deploy a custom domain with the Amplify Console" url: "https://read.acloud.guru/how-to-deploy-a-custom-domain-with-the-amplify-console-a884b6a3c0fc" author: "Nader Dabit" tags: ["hosting", "amplify"] --- In this tutorial, we’ll learn how to add a custom domain to an Amplify Console deployment in just a couple of minutes.
Looking over that content, our form must have these columns:
I’m using Bulma for styling, so the class names in use here are from that.
Now we write a JavaScript function that transforms a user’s input into a friendly URL that we can combine as GitHub query parameters on our pull request. Here is the step by step:
Get the user’s input about the content they want to add
Generate a string from all that content
Encode the string to format it in a way that humans can read
Attach the encoded string to a complete URL pointing to GitHub’s page for new pull requests
Here is the Pen:
After pressing the Submit button, the user is taken right to GitHub with an open pull request for this new file in the right location.
Quick caveat: Users still need a GitHub account to contribute. But this is still much easier than having to know how to fork a repo and create a pull request from that fork.
Other benefits of this approach
Well, for one, this is a form that lives on our site. We can style it however we want. That sort of control is always nice to have.
Secondly, since we’ve already written the JavaScript, we can use the same basic idea to talk with other services or APIs in order to process the input first. For example, if we need information from a website (like the title, meta description, or favicon) we can fetch this information just by providing the URL.
Taking things further
Let’s have a play with that second point above. We could simply pre-fill our form by fetching information from the URL provided for the user rather than having them have to enter it by hand.
With that in mind, let’s now only ask the user for two inputs (rather than four) — just the URL and tags.
How does this work? We can fetch meta information from a website with JavaScript just by having the URL. There are many APIs that fetch information from a website, but you might the one that I built for this project. Try hitting any URL like this:
The demo above uses that as an API to pre-fill data based on the URL the user provides. Easier for the user!
Wrapping up
You could think of this as a very minimal CMS for any kind of Static Site Generator. All you need to do is customize the form and update the pre-filled query parameters to match the data formats you need.
How will you use this sort of thing? The four sites we saw at the very beginning are good examples. But there are so many other times where might need to do something with a user submission, and this might be a low-lift way to do it.