Revision code

Changes Version 3.8.0
Released on June 21, 2024 with 100 commits

This release brings many improvements that were requested by the community.

The vast majority of these changes are backward compatible. Some breaking changes can be found with the Reworked style helpers. Existing calls are polyfilled by unpoly-migrate.js.

Lazy loading content

You can now lazy load additional fragments when a placeholder enters the DOM or viewport. By deferring the loading of non-critical fragments with a separate URL, you can paint important content earlier.

For example, you may have a large navigation menu that only appears once the user clicks a menu icon:

<div id="menu">
  Hundreds of links here

To remove the menu from the initial render pass, extract its contents to its own route, like /menu.

In the initial view, only leave a placeholder element and mark it with an [up-defer] attribute. Also set an [up-href] attribute with the URL from which to load the deferred content:

<div id="menu" up-defer up-href="/menu"> <!-- mark-phrase "up-defer" -->

When the [up-defer] placeholder is rendered, it will immediately make a request to fetch its content from /menu. You may also delay the request until the placeholder is scrolled into the viewport or control the timing from JavaScript.

See lazy loading content for a full example and more details.

For many years Unpoly has supported the [up-preload] attribute. This would preload a link when the user hovers over it:

<a href="/path" up-preload>Hover over me to preload my content</a>

You can now preload a link as soon as it appears in the DOM, by setting an [up-preload="insert"] attribute. This is useful for links with a high probability of being clicked, like a navigation menu:

<a href="/menu" up-layer="new drawer" up-preload="insert">≡ Menu</a> <!-- mark-phrase "insert" -->

To "lazy preload" a link when it is scrolled into the viewport, you can now set an [up-preload="reveal"] attribute. This is useful when an element is below the fold and is unlikely to be clicked until the the user scrolls:

<a href="/stories/106" up-preload="reveal">Full story</a> <!-- mark-phrase "reveal" -->

Infinite scrolling

Deferred fragments that load when revealed can implement infinite scrolling without custom JavaScript.

All you need is an HTML structure like this:

<div id="pages">
  <div class="page">items for page 1</div>

<a id="next-page" href="/items?page=2" up-defer="reveal" up-target="#next-page, #pages:after">
  load next page

See infinite scrolling for a full example and more details.

Enabling or disabling Unpoly features with boolean attributes

Most Unpoly attributes can now be enabled with a value "true" and be disabled with a value "false":

<a href="/path" up-follow="true">Click for single-page navigation</a> <!-- mark-phrase "true" -->
<a href="/path" up-follow="false">Click for full page load</a> <!-- mark-phrase "false" -->

Instead of setting a true you can also set an empty value:

<a href="/path" up-follow>Click for single-page navigation</a>
<a href="/path" up-follow="">Click for single-page navigation</a>
<a href="/path" up-follow="true">Click for single-page navigation</a>

Boolean values can be helpful with a server-side templating language like ERB, Liquid or Haml, when the attribute value is set from a boolean variable:

<a href="/path" up-follow="<%= is_signed_in %>">Click me</a> <%# mark-phrase "is_signed_in" %>

This can also help when you're generating HTML from a different programming language and want to pass a true literal as an attribute value:

link_to 'Click me', '/path', 'up-follow': true

This behavior is available for most attributes:

Request batching

When queueing multiple requests to the same URL, Unpoly will now send a single request with a merged X-Up-Target header.

For example, these two render passes render different selectors from /path:

up.render('.foo', { url: '/path', cache: true })
up.render('.bar', { url: '/path', cache: true })

Unpoly will send a single request with both targets:

GET /path HTTP/1.1
X-Up-Target: .foo, .bar

This allows you to have multiple deferred placeholders that load from the same URL efficiently.

More cache hits for tailored responses

The following is a change for server routes that use the Vary header to optimize their responses to only include the requested X-Up-Target.

When requests target multiple fragments and the server responds with a Vary header, that response is now a cache hit for each individual selector:

🠦 X-Up-Target: .foo, .bar
🠤 Vary: X-Up-Target
🠦 X-Up-Target: .foo ✔️ cache hit
🠦 X-Up-Target: .bar ✔️ cache hit
🠦 X-Up-Target: .foo, .bar ✔️ cache hit
🠦 X-Up-Target: .bar, .foo ✔️ cache hit
🠦 X-Up-Target: .baz ❌ cache miss
🠦 X-Up-Target: .foo, .baz ❌ cache miss
🠦 No X-Up-Target ❌ cache miss

See how cache entries are matched for a detailed example.

Cached content is retained while offline

This release fixes some long-standing issues where the cache was evicted when a request failed due to network issues, or when the server responds with an empty response.

This fix restores the indented behavior that, even without a connection, cached content will remain navigatable for 90 minutes. This means that an offline user can instantly access pages that they already visited this session.

Quick access to the form element in form events

Form-related events like up:form:submit and up:form:validate are emitted on the element that caused the event. For example, up:form:submit is emitted on the submit button that was pressed.

This made it somewhat inconvenient to access the form element:

up.on('up:form:submit', function(event) {
  let form ='form')
  console.log("form is", form)

You can now access the form element through a { form } property on the event object:

up.on('up:form:submit', function({ form }) {
  console.log("form is", form)

Improvements to history restoration

Several improvements have been made to the way Unpoly handles the browser's "back" button.

Ensuring fresh content

In earlier versions, when the user pressed the back button, Unpoly would sometimes restore the page with stale content.

Starting with 3.8.0, restored content is now revalidated with the server. This ensures that content is shown with the most recent data.

Custom restoration behavior

Listeners to up:location:restore may now mutate the event.renderOptions event to customize the render pass that is about to restore content:

up.on('up:location:restore', function(event) {
  // Update a different fragment when restoring /special-path  
  if (event.location === '/special-path') { = '#other'

As a reminder, you can also completely substitute Unpoly's render pass with your own restoration behavior, by preventing up:location:restore. This will prevent Unpoly from changing any element. Your event handler can then restore the page with your own custom code:

up.on('up:location:restore', function(event) {
  // Stop Unpoly from rendering anything
  // We will render ourselves
  document.body.innerText = `Restored content for ${event.location}!`

Reworked style helpers

This release reworks all functions that work with CSS properties:

Support for custom properties

All functions that work with CSS properties now also support custom properties ("CSS variables"):

// Returns the computed value of the `--custom-prop` property., '--custom-prop')

// Sets the `--custom-prop` property as an inline `[style]` attribute
up.element.setStyle(div, { '--custom-prop': 'value' })

Property names must be in kebab-case

In earlier versions Unpoly functions accepted property names in either camelCase or kebab-case.

As custom properties don't have a camelCase equivalent, now only kebab-case is supported:

// ❌ camelCase property names are no longer supported
up.element.setStyle(div, { backgroundColor: 'red' })

// ✔️ Property names must now be in kebab-case
up.element.setStyle(div, { 'background-color': 'red' })

To help with upgrading, unpoly-migrate.js Unpoly will rename camelCase keys for you.

Length values must have a unit

CSS requires length values (like width, top or margin) to have a unit, e.g. width: 200px. In earlier versions Unpoly silently added a px unit to length values that were missing a unit.

This approach required Unpoly to keep a list of CSS properties that denote lengths, which was unsustainable. You now always need to pass length values with a unit:

// ❌ Length values without unit is uo longer supported
up.element.setStyle(div, { height: 50 })

// ✔️ Length values now require a unit
up.element.setStyle(div, { height: '50px' })

To help with upgrading, unpoly-migrate.js Unpoly will add px units to unit-less length values.


The design of was reworked with fresh colors, better spacing and clearer fonts.

All documentation pages now have a table of contents to quickly find the section you're looking for.

Several new guides were also added:

Other changes

  • up.element.numberAttr() now parses negative numbers.
  • When updating history, the html[lang] is now also updated. This can be prevented by setting an [up-lang=false] attribute or passing a { lang: false } option.
  • The function up.util.microtask() was deprecated. Use the browser's built-in queueMicrotask() instead.
  • Right-anchored can now control their appearance while a scrolling overlay is open, by styling the .up-scrollbar-away class.
  • Fix a bug where the back button did not work after following a link that contains an anchor starting with a number (fixes #603).
  • Clickable elements now get an ARIA role of button. In earlier versions these elements received a link link role.
  • Fix a bug where animating with { duration: 0 } would apply the default duration instead of skipping the animation (fixes #588).
  • You can now exclude navigational containers from applying .up-current by adding a selector to


If you're upgrading from an older Unpoly version you should load unpoly-migrate.js to polyfill deprecated APIs. Changes handled by unpoly-migrate.js are not considered breaking changes.

See our upgrading guide for details.