Fragment support on this site is being achieved with:

  1. JavaScript for setting up fragment rules
  2. CSS for animating fragment visits
  3. The DOM for special cases and extra functionality

JavaScript

Install swup and fragment-plugin from NPM:

Terminal window
npm install swup @swup/fragment-plugin

The following code is being used on this very site for initializing swup with fragment support:

import Swup from "swup";
import SwupFragmentPlugin, { Rule as FragmentRule } from "@swup/fragment-plugin";

/**
 * Define the rules for Fragment Plugin
 */
const rules: FragmentRule[] = [
  // Rule 1: Between the various views of the characters list
  {
    from: "/characters/:filter?",
    to: "/characters/:filter?",
    containers: ["#characters-list"],
    scroll: '#characters-list'
  },
  // Rule 2: From the list of characters to a character detail page
  {
    from: "/characters/:filter?",
    to: "/character/:character",
    containers: ["#character-modal"],
    name: "open-character",
    scroll: true
  },
  // Rule 3: From a single character back to the list of characters
  {
    from: "/character/:character",
    to: "/characters/:filter?",
    containers: ["#character-modal", "#characters-list"],
    name: "close-character",
  },
  // Rule 4: Between characters (previous/next)
  {
    from: "/character/:character",
    to: "/character/:character",
    containers: ["#character-detail"],
  },
];

/**
 * Initialize Swup with Fragment Plugin
 */
const swup = new Swup({
  plugins: [
    new FragmentPlugin({ rules }),
  ],
});

Learn more about rules

Did you notice something strange about rule 3? It’s telling the plugin to not only replace #character-modal but also #characters-list! Surely that’s not we want when we are only closing an overlay, right?

Turns out: There are cases where we actually want that! When navigating from a character to a filtered list of characters that wasn’t active before we opened the character. Or when jumping multiple steps backwards or forwards in the browser history.

Fragment Plugin keeps track of the URL for each fragment element as soon as it is being rendered. On subsequent visits, the element won’t be replaced if it already matches the visit’s URL.

CSS

The CSS for the animations of fragment visits is scoped to the fragment elements:

/* The default transition for non-fragment visits */
.is-changing .transition-main {
  transition-property: opacity, transform;
  transition-duration: 250ms;
}
html.is-animating .transition-main {
  opacity: 0;
  transform: translateY(-20px);
}
html.is-leaving .transition-main {
  transform: translateY(20px);
}

/*
 * The transition when filtering the characters.
 * Here, we are animating the `.teaeser` elements individually
 */
#characters-list.is-changing {
  --duration-leave: 150ms;
  --duration-enter: 400ms;
  transition-duration: var(--duration-enter);
}
#characters-list.is-leaving {
  transition-duration: var(--duration-leave);
}
#characters-list.is-changing .teaser {
  transition-property: opacity, transform;
  transition-duration: var(--duration-enter);
  transition-timing-function: cubic-bezier(0.230, 1.000, 0.320, 1.000); /* easeOutQuint */
}
#characters-list.is-animating .teaser {
  opacity: 0;
  transform: scale(0.75);
}
/* Change easing and duration for the leave-phase */
#characters-list.is-leaving .teaser {
  transition-timing-function: ease-in;
  transition-duration: var(--duration-leave);
}

/*
* The animation for opening and closing the character modal
*/
#character-modal.is-changing {
  transition: opacity 300ms;
}
#character-modal.is-animating {
  opacity: 0;
}

/* Zoom-Effect for the character detail when opening/closing the modal */
#character-modal.is-changing #character-detail {
  transition: transform 150ms;
  transform: scale(1);
}
#character-modal.to-open-character.is-animating #character-detail,
#character-modal.to-close-character.is-leaving #character-detail {
  transform: scale(0.95);
}

/* Animate the backdrop of the modal */
#character-modal::backdrop {
  transition: opacity 300ms;
}
#character-modal.is-animating::backdrop,
.is-animating #character-modal::backdrop {
  opacity: 0;
}

/*
* The animation between charcter details. Directional animation based on data-swup-animation
*/
#character-detail.is-changing {
  transition: opacity 200ms, transform 200ms;
}
#character-detail.to-next {
  --direction: 1;
}
#character-detail.to-previous {
  --direction: -1;
}
#character-detail.is-animating {
  opacity: 0;
  transform: translateX(calc(40px * var(--direction)));
}
#character-detail.is-leaving {
  transform: translateX(calc(40px * var(--direction) * -1));
}

DOM

[data-swup-fragment-url]

Let’s visit one of the character detail pages directly, Luigi, for example.

On that page, the #characters-list behind the #character-modal displays the items that would also be rendered when navigating towards /characters/. But there is no way for Fragment Plugin to automatically detect that fact, so it would replace the list upon closing the modal, following our third fragment rule:

{
from: "/character/:character",
to: "/characters/:filter?",
containers: ["#character-modal", "#characters-list"],
name: "close-character",
}

We can solve this by providing the URL of the #characters-list beforehand, by adding data-swup-fragment-url="/characters/" to it. Now Fragment Plugin can correctly skip the #characters-list when we navigate from the open #character-modal to /characters/:

/character/luigi/index.html
<main id="character-modal">
<!-- ...modal content... -->
</main>
<div id="characters-list" data-swup-fragment-url="/characters/">
<!-- ...filters & characters... -->
</div>

Learn more about this attribute

Using [data-swup-link-to-fragment="<selector>"], you can tell any link on your site to be synced to the matching element’s fragment URL. This is in use for the character modal’s close links on this site:

/character/luigi/index.html
<main id="character-modal">
<!-- Tells the link to sync it's `href` to the fragment #characters-list: -->
<a href="" data-link-to-fragment="#characters-list">Close modal</a>
</main>
<div id="characters-list" data-swup-fragment-url="/characters/">
<!-- ...filters & characters... -->
</div>

Learn more about this attribute

Modals inside transformed parents

Suppose you have a fragment that you want to open like a modal, above all other content. Just like on the character detail pages on this site.

This (reduced) CSS is being used to make the #item-detail appear as a modal above everything else:

.modal {
position: fixed;
inset: 0;
z-index: 99999;
}

This will work fine, until we apply a transform to any of the modal’s parent elements, as we do on this site:

html.is-changing .transition-main {
transition: opacity 250ms, transform 250ms;
}
html.is-animating .transition-main {
opacity: 0;
/* `transform` will misplace the .modal's positioning during an animated page visit */
transform: translateY(20px);
}

The reason for this is that transform establishes a containing block for all descendants.

You have two options to fix this:

  1. Don’t apply CSS transforms to any of the parents of a modal
  2. Use <detail open> for the modal:
<div id="swup" class="transition-main">
<main id="character-modal">
<dialog open id="character-modal"><main>
<!-- character content -->
</main>
</main></dialog>
<div id="characters-list" data-swup-fragment-url="/characters/">
<!-- ...filters & characters... -->
</div>
</div>

Fragment Plugin will detect <detail open> fragment elements automatically on every page view and run showModal() on them, putting them on the top layer and thus allows them to not be affected by parent element styles, anymore.

Modals and accessibility

Element order

The first <main> element in a document will be considered the main content by assistive technology. If you are using the A11y Plugin, that’s also the element that will automatically be focused upon page visits. For that reason, you should always put your modal before any overlayed content, if it should be considered the primary content of a page.

Pros and cons of using a <dialog open> element for modals

Pros:

Cons: