Modal dialog with Hyperscript and PicoCSS

Monday 10 November 2025 Β· 18 mins read Β· Viewed 3 times

Table of contents πŸ”—

TL;DR πŸ”—

 1<button _="on click call #register-dialog.showModal()">Open dialog</button>
 2<dialog id="register-dialog">
 3  <article
 4    _="on click[#register-dialog.open and event.target.matches('dialog')] from elsewhere call #register-dialog.close()"
 5  >
 6    <header>
 7      <button
 8        aria-label="Close"
 9        rel="prev"
10        _="on click call #register-dialog.close()"
11      ></button>
12      <p>
13        <strong>πŸ—“οΈ Thank You for Registering!</strong>
14      </p>
15    </header>
16    <p>
17      We're excited to have you join us for our upcoming event. Please arrive at
18      the museum on time to check in and get started.
19    </p>
20    <ul>
21      <li>Date: Saturday, April 15</li>
22      <li>Time: 10:00am - 12:00pm</li>
23    </ul>
24  </article>
25</dialog>

πŸ—“οΈ Thank You for Registering!

We're excited to have you join us for our upcoming event. Please arrive at the museum on time to check in and get started.

  • Date: Saturday, April 15
  • Time: 10:00am - 12:00pm

How-to create a dialog modal with PicoCSS and vanilla JS πŸ”—

PicoCSS offers a dialog modal directly in the CSS, without any additional JS:

πŸ—“οΈ Thank You for Registering!

We’re excited to have you join us for our upcoming event. Please arrive at the museum on time to check in and get started.

  • Date: Saturday, April 15
  • Time: 10:00am - 12:00pm
dialog.html
 1<dialog open>
 2  <article>
 3    <header>
 4      <button aria-label="Close" rel="prev"></button>
 5      <p>
 6        <strong>πŸ—“οΈ Thank You for Registering!</strong>
 7      </p>
 8    </header>
 9    <p>
10      We're excited to have you join us for our upcoming event. Please arrive at
11      the museum on time to check in and get started.
12    </p>
13    <ul>
14      <li>Date: Saturday, April 15</li>
15      <li>Time: 10:00am - 12:00pm</li>
16    </ul>
17  </article>
18</dialog>

Credits to PicoCSS docs

However, there is a small issue: the example is not interactive!

Thankfully, PicoCSS also offers an example to open and close using JS. However, the give is quite long:

dialog.js
 1/*
 2 * Modal
 3 *
 4 * Pico.css - https://picocss.com
 5 * Copyright 2019-2024 - Licensed under MIT
 6 */
 7
 8// Config
 9const isOpenClass = 'modal-is-open';
10const openingClass = 'modal-is-opening';
11const closingClass = 'modal-is-closing';
12const scrollbarWidthCssVar = '--pico-scrollbar-width';
13const animationDuration = 400; // ms
14let visibleModal = null;
15
16// Toggle modal
17const toggleModal = (event) => {
18  event.preventDefault();
19  const modal = document.getElementById(event.currentTarget.dataset.target);
20  if (!modal) return;
21  modal && (modal.open ? closeModal(modal) : openModal(modal));
22};
23
24// Open modal
25const openModal = (modal) => {
26  const { documentElement: html } = document;
27  const scrollbarWidth = getScrollbarWidth();
28  if (scrollbarWidth) {
29    html.style.setProperty(scrollbarWidthCssVar, `${scrollbarWidth}px`);
30  }
31  html.classList.add(isOpenClass, openingClass);
32  setTimeout(() => {
33    visibleModal = modal;
34    html.classList.remove(openingClass);
35  }, animationDuration);
36  modal.showModal();
37};
38
39// Close modal
40const closeModal = (modal) => {
41  visibleModal = null;
42  const { documentElement: html } = document;
43  html.classList.add(closingClass);
44  setTimeout(() => {
45    html.classList.remove(closingClass, isOpenClass);
46    html.style.removeProperty(scrollbarWidthCssVar);
47    modal.close();
48  }, animationDuration);
49};
50
51// Close with a click outside
52document.addEventListener('click', (event) => {
53  if (visibleModal === null) return;
54  const modalContent = visibleModal.querySelector('article');
55  const isClickInside = modalContent.contains(event.target);
56  !isClickInside && closeModal(visibleModal);
57});
58
59// Close with Esc key
60document.addEventListener('keydown', (event) => {
61  if (event.key === 'Escape' && visibleModal) {
62    closeModal(visibleModal);
63  }
64});
65
66// Get scrollbar width
67const getScrollbarWidth = () => {
68  const scrollbarWidth =
69    window.innerWidth - document.documentElement.clientWidth;
70  return scrollbarWidth;
71};
72
73// Is scrollbar visible
74const isScrollbarVisible = () => {
75  return document.body.scrollHeight > screen.height;
76};

Given the size of the script, I would have extracted this code in a JS file, but I wanted to keep the logic close to the HTML element triggering it.

This is where Hyperscript comes in.

Hyperscript, the missing piece for homemade SSR πŸ”—

Difficulties with homemade SSR with HTMX πŸ”—

When doing a homemade SSR server, the server becomes "fat". It is responsible to render the final HTML, instead of an SPA, where the client renders the HTML.

The motivation for SSR comes simply by a wish to go back to the good old days of the web: the server serves a proper page and the client simply renders the page, without any complexity. This has many advantages, with one being simply the performance. Servers are known to be strong, whereas clients are not, especially modern browsers.

When implementing SSR, most of the time, people will prefer full-stack solutions like NextJS or SvelteKit. But the biggest issue with these frameworks is that they are heavy, and the programming language Typescript doesn't offer enough safety.

This is why, some people will choose to use HTMX, a solution to do SSR by serving data using HTML. For example, instead of serving:

 1{
 2  "results": [
 3    {
 4      "title": "My blog article"
 5    },
 6    {
 7      "title": "My second blog article"
 8    }
 9  ]
10}

HTMX would serve a properly rendered HTML fragment:

1<section>
2  <ul>
3    <li>My blog article</li>
4    <li>My second blog article</li>
5  </ul>
6</section>

This offers these advantages:

  • No JS required, which improves performance
  • Locality of behavior
  • Agnostic to the programming language
  • The server is fat, and the client is thin

But has these drawbacks:

  • No front-end framework
    • And if there are, it requires some compilation (React, Vue, etc.)
    • And if they don't require compilation, their performance can be doubtful (see alpine, as it is often the most recommended framework to use with HTMX)
  • Styling is complicated

Hyperscript as a simple solution πŸ”—

Hyperscript is a small library that allows to write simple script alongside the HTML, without disrupting the lisibility of the code.

To install it, simple put in the index.html:

1<script src="https://unpkg.com/hyperscript.org@0.9.14"></script>

Then, you can write:

1<button _="on click send hello to <form />">Send</button>

...I know. This is a new syntax to learn, and honestly, I still have a hard time with it.

Other drawbacks are:

  • No compilation, i.e, no compile-time checks.
  • The syntax itself isn't clear due to its "almost-English" nature.
  • CSP issues (see Security - Hyperscript)

Dialog Modal with Hyperscript and PicoCSS πŸ”—

Even though Hyperscript has some issues, it is still more clearer than the script above. Here's the implementation of a dialog using Hyperscript:

 1<button _="on click call #register-dialog.showModal()">Open dialog</button>
 2<dialog id="register-dialog">
 3  <article
 4    _="on click[#register-dialog.open and event.target.matches('dialog')] from elsewhere call #register-dialog.close()"
 5  >
 6    <header>
 7      <button
 8        aria-label="Close"
 9        rel="prev"
10        _="on click call #register-dialog.close()"
11      ></button>
12      <p>
13        <strong>πŸ—“οΈ Thank You for Registering!</strong>
14      </p>
15    </header>
16    <p>
17      We're excited to have you join us for our upcoming event. Please arrive at
18      the museum on time to check in and get started.
19    </p>
20    <ul>
21      <li>Date: Saturday, April 15</li>
22      <li>Time: 10:00am - 12:00pm</li>
23    </ul>
24  </article>
25</dialog>

πŸ—“οΈ Thank You for Registering!

We're excited to have you join us for our upcoming event. Please arrive at the museum on time to check in and get started.

  • Date: Saturday, April 15
  • Time: 10:00am - 12:00pm

This is what I'm talking about locality of behavior. Thanks to the syntax of Hyperscript, there is no need to search for an element using verbose functions such as document.getElementById, and the code is more clear and readable. The only thing I need to example is:

1<article
2  _="on click[#register-dialog.open and event.target.matches('dialog')] from elsewhere call #register-dialog.close()"
3></article>

which means:

  • I attach an event listener and listen for events only when the dialog is open and the event target is a dialog (the user has clicked on the hitbox of the dialog, this also includes the background).
  • If there is an event that is not hitting the <article> (elsewhere of the <article>), I close the dialog.

Pretty cool, huh?

Conclusion πŸ”—

This is just a small article about this deadly combination. I know you may simply not like Hyperscript, and would criticize it as an alternative to jQuery or vanilla JS, but I think it's better than them since I can write the code alongside the HTML, and it's more readable.

Also, if the syntax of hyperscript is too difficult, hyperscript is also able to call JS code, which makes it a good extension of HTML.