Zero Magic

by Patrik Duditš

Unpoly Client-Side Capabilities

18 Mar 2026
Unpoly Client-Side Capabilities
Photo by Matteo Vella

Web applications that use Unpoly will primarily use HTML markup and custom attributes to drive interaction. It is not against the philosophy to use JavaScript — quite the opposite. Unpoly offers a very open API that lets custom code and Unpoly interact very comfortably. Unlike previous posts, this one will contain live code snippets, so you can experience Unpoly first-hand.

Attaching behavior with the Unpoly Compiler

At its core, Unpoly is built around a simple concept: many handlers register an intent to be notified about elements that satisfy a particular CSS selector and then attach behavior to those elements. Behavior in this context usually means adding event listeners, but it can really be any kind of transformation, whether it's changing attributes, adding nodes or modifying CSS classes. The handlers can also provide callbacks for when the matching element is going to be detached from the DOM so they can clean up after themselves. We refer to this heart of Unpoly as the compiler.

The compiler watches as new fragments come and go in the current document, invoking their registered behavior and clean-up logic. You will usually put all your compiler registrations in a global per-site file. This is the preferred way of connecting custom JavaScript with elements of the page. Other ways exist, such as sending <script> elements from the server, but almost any JavaScript task can be done with compilers, and the compiler approach usually ends up being more robust and reusable.

Event handlers

A typical compiler registration looks like this:

up.compiler('button[make-alert]', (element) => {
    // We could use standard addEventListener here, but then we should track when the element
    // is going to be destroyed. Instead, use helper function up.on which directly returns
    // the cleanup function to remove the listener.
    return [ up.on(element, 'click', (evt) => alert(element.getAttribute('make-alert')))];
    // Since we return it from this handler, the compiler will make sure to invoke it
    // when the element is about to leave the DOM.
});

Let's see that in action:

<button make-alert="Compiled successfully">Alert!</button>

Let's do one more. PicoCSS , a minimal CSS framework I also use for this blog, expects invalid form elements to use the following markup:

<input type=text aria-invalid=true value="Invalid">
<small>Invalid input</small>

It looks like this:

Invalid input

This is clean markup that the backend can provide when returning form validation errors, but what if we want to use it to present client-side errors as well? Try interacting with this form:

That form's markup looks like this — and you could inspect it with View Page Source as if it were the early 2000s. No empty page with just an <app> element like we see with some... other JavaScript libraries.

<form onsubmit="return false;">
<input type=text pattern="[A-Z]{2}\d{3,4}[A-Z]{2}" required
  placeholder="Registration plate"
  title="Enter registration plate in format AA123AA">
<small></small>
<button type="submit">Test</button>
</form>

And the compiler function looks like this:

up.compiler("input[pattern]", (element) => {
    function propagateError(evt) {
        // pass the validation message to <small> element next to input
        const small = evt.target.nextElementSibling;
        small.textContent = evt.target.validationMessage;
        // set aria-invalid
        evt.target.setAttribute('aria-invalid', 'true');
        // do not display browser standard error indication
        evt.preventDefault();
    }
    // re-validate after change
    function validate(evt) {
        if (evt.target.checkValidity()) {
            // set to valid and clear message contents
            evt.target.setAttribute('aria-invalid', 'false');
            evt.target.nextElementSibling.textContent=null;
        }
    }
    // you can register clean-up functions for a behavior
    return [up.on(element, "invalid", propagateError), up.on(element, "change", validate)];
});

Timezone tricks

Giving a sensible representation of time is always tricky for a server-side application once the user's time zone and locale differ from the server's — unless the developer deeply cares about this kind of experience. Handling it properly typically requires a preferences dialog and server-side storage.

Let's try something different, though. Assume that the server serves all timestamps using the HTML <time> element:

<time datetime="2026-03-18T11:00:07.982773615Z">Server's default representation of time, such as 18. 03. 2026, 11:00</time>

We'll use the compiler and the browser's Date API to render that in the user's local time zone and regional settings. This can be overridden by data attributes on the <body> element, which is something we'll use a little later in the article.

up.compiler('time[datetime]', (element) => {
    const timeAttr = element.getAttribute('datetime');

    const date = new Date(timeAttr);
    const locale = document.body.dataset.dateLocale || new Intl.DateTimeFormat().resolvedOptions().locale;
    const formatter = new Intl.DateTimeFormat(locale, {
        dateStyle: document.body.dataset.dateStyle || 'short',
        timeStyle: document.body.dataset.timeStyle || 'short'
    });
    element.textContent = formatter.format(date);
});

The resulting element shows the time in your operating system's locale and current time zone:

The compiler can also work in the opposite direction: sending the user's time zone to the server. What if you want to interpret what the user meant when they entered a meeting start time of 09:55?

up.compiler("input[type=hidden][name=usertz]", function(element) {
    element.value = new Intl.DateTimeFormat().resolvedOptions().timeZone;
});

With that, any time you put <input type=hidden name=usertz> into a form, you'll get the user's time zone submitted alongside the local time:

<form onsubmit="alert(`Sending ${JSON.stringify(Object.fromEntries(new FormData(event.target)))}`); event.preventDefault();">
  <input type=hidden name=usertz>
  <label for="start">Meeting Start</label>
  <div role="group">
    <input type="time" name="start" value="09:55">
    <button>Send</button>
  </div>
</form>

Load third-party library or web component

The example application uses the third-party web component lit-line to render line charts, loaded the first time it appears on a page:

Three lit-line charts loading on the page
up.compiler('lit-line', (element) => {
    if (window.customElements.get('lit-line')) {
        return;
    }
    // if custom element is not defined, load the script.
    let body = document.body;
    // load it only once though
    if (body.dataset.litLineLoaded === 'true') {
        return;
    } else {
        body.dataset.litLineLoaded = 'true';
    }
    let head = document.head;
    let script = document.createElement('script');
    script.type = 'module';
    script.src = '/webjars/lit-line/0.3.2/cdn/lit-line.js';
    head.appendChild(script);
});

Because three chart elements appear on the demo page at the same time, we use the litLineLoaded flag to signal to the other compiler callbacks that the script is already being loaded — preventing three duplicate <script> elements from being added to <head>.

More interactive frontend elements

A similar loading trick can be used to bootstrap a React or Vue island. The demo application showcases this using Alpine.js .

// literally same on-demand loading for alpine
up.compiler('[x-data]', (element) => {
    if (!window.Alpine) {
        if (!document.body.dataset.alpineLoaded) {
            document.body.dataset.alpineLoaded = 'true';
            let head = document.head;
            let script = document.createElement('script');
            script.src = '/webjars/alpinejs/3.14.1/dist/cdn.min.js';
            head.appendChild(script);
        }
    }
});

// handle custom event dispatched from alpine -- set date options used by time compiler above
up.on('dateformat:changed', (event) => {
    console.log("Date format changed to", event.detail);
    document.body.dataset.dateLocale = event.detail.locale;
    document.body.dataset.dateStyle = event.detail.dateStyle;
    document.body.dataset.timeStyle = event.detail.timeStyle;
});

The demo application uses this to let the user override the OS locale for the time formatter shown earlier:

<article x-data="{
    locale: document.body.dataset.dateLocale,
    dateStyle: document.body.dataset.dateStyle || 'short',
    timeStyle: document.body.dataset.timeStyle || 'short',
    }" x-effect="$dispatch('dateformat:changed',{locale, dateStyle, timeStyle})">
    <form>
      <label>Locale
        <select name="locale" x-model="locale">
          <option value="en-US" :selected="locale==$el.value">English (US)</option>
          <option value="de-DE" :selected="locale==$el.value">German</option>
          <option value="sk-SK" :selected="locale==$el.value">Slovak</option>
        </select>
      </label>
      <label>Date format
        <select name="dateStyle" x-model="dateStyle">
          <option value="full" :selected="dateStyle==$el.value">Full</option>
          <option value="long" :selected="dateStyle==$el.value">Long</option>
          <option value="medium" :selected="dateStyle==$el.value">Medium</option>
          <option value="short" :selected="dateStyle==$el.value">Short</option>
        </select>
      </label>
      <label>Time format
        <select name="timeStyle" x-model="timeStyle">
          <option value="full" :selected="timeStyle==$el.value">Full</option>
          <option value="long" :selected="timeStyle==$el.value">Long</option>
          <option value="medium" :selected="timeStyle==$el.value">Medium</option>
          <option value="short" :selected="timeStyle==$el.value">Short</option>
        </select>
      </label>
    </form>
</article>

Date sample:

Preserving the state of elements

If you want to preserve the state of an element on the page, use the up-keep attribute. A typical example of such an element would be an audio player or a map widget:

<audio id="player" up-keep>

This element will not be replaced on the page if it already exists. It needs to be uniquely identifiable, either by an id attribute or a unique class. The Unpoly documentation uses the term matching derived target for this requirement, but in 90% of cases an id or a unique class is all you need.

With optional values, the element is kept only if its markup matches (up-keep=same-html) or its data- attributes match (up-keep=same-data). You would usually use this mode with compilers, as they support the data attributes as a convenient second argument to the compiler function.

Controlling Unpoly Behavior

While the primary value of using Unpoly is a better contract between server-rendered HTML and browser behavior, it also offers a very comprehensive client-side API to tailor it for what your application needs and fine-tune how it behaves.

The Config Object

Almost every default behavior I mentioned in this series is a default because it is defined as such somewhere in the depths of an object structure of the form up.*.config. For example, to drop the need for up-follow on every link, you would add a statement like this on page load:

up.link.config.followSelectors.push('a[href]');

The reason up-follow works in the first place is that by default this array contains the selector [up-follow].

There are some reasonable exceptions to when following with Unpoly makes sense and they are already defined in noFollowSelectors, such as cross-origin links.

There are dozens of small things that Unpoly does to make navigation feel natural: updating browser history, adjusting scroll position, and revalidating the cache. All of these have a corresponding configuration parameter described in the documentation .

Events

To intercept or observe individual actions the library takes, there are many custom events you can subscribe to.

For example, the event up:fragment:loaded is dispatched when a new fragment is ready to be inserted, and you can customize how — or whether — the replacement happens. Custom headers can be added to outgoing requests by modifying event.request in the up:request:load handler.

The example application highlights what changed on the page by listening to up:fragment:inserted and toggling a few classes:

up.on('up:fragment:inserted',
    (event, fragment) => {
        fragment.classList.add('new-fragment', 'inserted')
        up.util.timer(1000, () => fragment.classList.remove('inserted'))
        up.util.timer(3000, () => fragment.classList.remove('new-fragment'))
});

A CSS transition then gradually animates the outline:

.new-fragment {
    transition: outline-color 2s ease;
    outline: rgba(218, 190, 115, 0) 12px solid !important;
    &.inserted {
        outline-color: rgba(218, 190, 115, 0.7) !important;
    }
}

Invoking fragment replacement programmatically

You can also trigger fragment replacement from your own UI logic. There are two functions for this: up.navigate and up.render. The former is a special case of the latter, with its default options set to perform all the side effects that following a link would — including updating browser history.

For example, to immediately reflect the chosen format in the time rendering demo, we call:

up.render({
    target: '#sample-time',
    fallback: ':none',
    content: `Example: <time datetime="${new Date().toISOString()}">${new Date()}</time>`
})

Conclusion

The Unpoly compiler bridges server-side HTML and client-side JavaScript in a way that fits the interaction model well. We've shown relatively simple examples, but they compose very well to make complex interactions. Using just simple compilers, I once built a custom frontend for JIRA with full keyboard navigation in about 100 lines of JavaScript.

We're nowhere near finished enumerating Unpoly's features. We skipped over many details and side effects that happen during fragment replacement: history handling, scrolling, aborting duplicate requests, and offline behavior. All of these are very well documented — the official documentation is the best place to learn everything about them.

We also haven't yet covered layers, which is an extremely powerful feature that deserves a post of its own. Layers let you perform multi-step interactions in an isolated fragment of the page and connect back to where you started without losing client state. Stay tuned!

Share on Mastodon

e.g., mastodon.social or https://mastodon.social