Fragment
Replace only some containers for some visits, such as the detail panel of a list, and leave the rest of the page alone.
Import it from pagefold/fragment (about 1.4 KB minified and compressed, shared code included).
import PagefoldFragment from 'pagefold/fragment'
new Pagefold({
containers: ['#list', '#detail'],
plugins: [
new PagefoldFragment({
rules: [
{ from: '/users', to: '/users/:id', containers: ['#detail'], name: 'detail' },
{ from: '/users/:id', to: '/users', containers: ['#detail'] },
],
}),
],
})
Going from /users to /users/7 replaces only #detail. The list, with its scroll position and its state, is not touched. Because rules match on the page you leave and the page you enter, the way back needs a rule of its own, as in the second entry.
Here is what the visit does to the page, with #list and #detail as containers:
#list scrolled, filtered #detail nothing selected #list scrolled, filtered untouched #detail user 7 replaced Try it. Type in the filter, scroll the list and open a person. The time on the list never changes, because the list is never replaced, and the time on the detail does, because it is new on every visit. Your filter and your scroll stay where they were.
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ada Lovelace · Fragment</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="app">
<aside id="list">
<header>
<h2>People</h2>
<p>List rendered at <b class="stamp">--:--:--</b></p>
<input type="search" placeholder="Filter the list" aria-label="Filter the list">
</header>
<ul>
<li><a href="index.html"><span class="badge">AL</span><span>Ada Lovelace<small>Mathematician</small></span></a></li>
<li><a href="grace.html"><span class="badge">GH</span><span>Grace Hopper<small>Rear admiral</small></span></a></li>
<li><a href="linus.html"><span class="badge">LT</span><span>Linus Torvalds<small>Engineer</small></span></a></li>
<li><a href="margaret.html"><span class="badge">MH</span><span>Margaret Hamilton<small>Software engineer</small></span></a></li>
<li><a href="alan.html"><span class="badge">AT</span><span>Alan Turing<small>Mathematician</small></span></a></li>
<li><a href="katherine.html"><span class="badge">KJ</span><span>Katherine Johnson<small>Mathematician</small></span></a></li>
<li><a href="tim.html"><span class="badge">TB</span><span>Tim Berners-Lee<small>Computer scientist</small></span></a></li>
<li><a href="radia.html"><span class="badge">RP</span><span>Radia Perlman<small>Network engineer</small></span></a></li>
</ul>
</aside>
<section id="detail" class="transition-detail">
<span class="avatar">AL</span>
<h1>Ada Lovelace</h1>
<p class="role">Mathematician</p>
<p>Wrote the first published program, for the Analytical Engine of Charles Babbage.</p>
<p class="rendered">Detail rendered at <b class="stamp">--:--:--</b></p>
</section>
</div>
<script src="../_lib/pagefold.js"></script>
<script src="../_lib/fragment.js"></script>
<script src="main.js"></script>
</body>
</html> main.js
const time = () => new Date().toLocaleTimeString([], { hour12: false })
const list = document.querySelector('#list')
// The list is drawn once. Its time never changes, however many people you open.
list.querySelector('.stamp').textContent = time()
const pagefold = new Pagefold({
containers: ['#list', '#detail'],
plugins: [
// Every visit replaces only #detail, so the list keeps its scroll and what you typed
new PagefoldFragment({ rules: [{ from: '*', to: '*', containers: ['#detail'] }] }),
],
})
// The detail is new on every visit, so its time changes
const stampDetail = () => (document.querySelector('#detail .stamp').textContent = time())
stampDetail()
pagefold.hooks.on('content:replace', stampDetail)
// The list stays, so it has to learn by itself who is open now
const markActive = () =>
list.querySelectorAll('li a').forEach((a) => {
a.classList.toggle('is-active', new URL(a.href).pathname === location.pathname)
})
markActive()
pagefold.hooks.on('page:view', markActive)
// A filter: its text survives the visits, because the list is not replaced
list.querySelector('input').addEventListener('input', (event) => {
const text = event.target.value.toLowerCase()
list.querySelectorAll('li').forEach((li) => (li.hidden = !li.textContent.toLowerCase().includes(text)))
}) style.css
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;font:16px/1.6 system-ui,sans-serif;background:#0a0a0a;color:#fafaf8}
/* Links: the accent color with a quiet underline that gets stronger on hover */
a{color:#5af0ae;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:.25em;text-decoration-color:rgba(90,240,174,.5);transition:color .15s,text-decoration-color .15s}
a:hover{color:#00ff9c;text-decoration-color:currentColor}
a:focus-visible{outline:2px solid #5af0ae;outline-offset:3px;border-radius:2px}
.app{display:grid;grid-template-columns:16rem 1fr;height:100%}
/* The list: it never changes while you move between people */
#list{overflow:auto;border-right:1px solid #2c2c2c;background:#101010}
#list header{position:sticky;top:0;padding:1rem 1rem .75rem;background:#101010;border-bottom:1px solid #2c2c2c}
#list h2{margin:0;font-size:.8rem;letter-spacing:.08em;text-transform:uppercase;color:#a3a39e}
#list header p{margin:.25rem 0 .75rem;font-size:.8rem;color:#a3a39e}
#list b,#detail b{color:#fafaf8;font-variant-numeric:tabular-nums}
#list input{width:100%;padding:.5rem .65rem;border-radius:8px;border:1px solid #2c2c2c;background:#1a1a1a;color:inherit;font:inherit}
#list input:focus{outline:2px solid #5af0ae;outline-offset:1px}
#list ul{list-style:none;margin:0;padding:.5rem}
#list li[hidden]{display:none}
#list li a{display:flex;gap:.65rem;align-items:center;padding:.5rem .6rem;border-radius:8px;color:#fafaf8;text-decoration:none;border-left:3px solid transparent}
#list li a:hover{background:#1a1a1a}
#list li a.is-active{background:#1a1a1a;border-left-color:#00ff9c}
#list small{display:block;color:#a3a39e;font-size:.78rem;line-height:1.2}
.badge{display:grid;place-items:center;flex:none;width:2.1rem;height:2.1rem;border-radius:50%;background:#1b1bff;color:#fafaf8;font:700 .75rem system-ui}
/* The detail: the only part that is replaced */
#detail{padding:2rem;overflow:auto;transition:opacity .25s ease,transform .25s ease}
#detail.is-animating{opacity:0;transform:translateY(10px)}
.avatar{display:grid;place-items:center;width:4rem;height:4rem;border-radius:50%;background:#1b1bff;color:#fafaf8;font:700 1.25rem system-ui}
#detail h1{margin:1rem 0 0;font-size:1.9rem;line-height:1.2}
#detail .role{margin:.1rem 0 1rem;color:#00ff9c;font-size:.9rem}
#detail p{max-width:32rem;color:#c9c9c4}
#detail .rendered{margin-top:1.5rem;padding:.55rem .8rem;display:inline-block;border:1px dashed #2c2c2c;border-radius:8px;font-size:.85rem;color:#a3a39e}
@media (max-width:34rem){.app{grid-template-columns:11rem 1fr}#detail{padding:1.25rem}} A rule
| Field | What it does |
|---|---|
from, to | Path patterns, or a list of them. :name and * work as in JS. |
containers | The only containers replaced by matching visits |
name | Becomes the animation name, as data-pagefold-animation would |
scroll | Scroll after the visit. true goes to the top, a selector goes to that element, and a function (visit) => boolean | string decides for each visit. Fragment visits keep the scroll position by default |
focus | With the A11y plugin: a selector to focus after these visits, or false to leave focus alone |
if | (visit) => boolean. Return false to skip the rule for that visit, even when the paths match |
Animations
The animation classes go on the fragment containers, not on the html element, so the rest of the page is not animated. Style it like any other visit:
#detail.is-animating {
opacity: 0;
}
When it falls back
A rule is only used if every container in the rule is on the current page. And if the new page does not contain one of them, the rule is dropped for that visit and Pagefold replaces the normal containers instead, so you never end up with a half-updated page.
That works when a normal container is bigger than the fragment. Here #pagefold is the normal container and #detail is inside it:
#pagefold contains #detail and the rest of the page #pagefold no #detail on this page: everything inside is replaced replaced If the new page is also missing one of the normal containers, there is nothing safe to replace. The visit fails and the browser loads the page normally, as for any visit that cannot be done.
visit.fragment tells you whether a visit is a fragment visit.
Finding out why a rule does not apply
Set debug: true and Pagefold prints one line per rule for each visit: used, or why it was skipped (from does not match, to does not match, a container is not on the page, if() returned false).
new PagefoldFragment({ rules, debug: true })
matchOptions (ignoreCase, strict, end) changes how the paths of the rules match.
Common problems
- The way back replaces everything. A rule needs the page you leave and the page you enter. Add a second rule for the return trip.
- A rule never applies. Every container in the rule must exist on the current page, and both patterns must match. Turn on the Debug plugin to see what the visit does.
- The scroll position resets. Fragment visits keep it unless the rule has
scroll: true.
Options
| Option | Type | Default | What it does |
|---|---|---|---|
rules | FragmentRule[] | [] | Rules, checked in order. Each has from, to, containers, and optionally name and scroll. |
matchOptions | { ignoreCase?, strict?, end? } | {} | How path patterns are matched: ignoreCase (default false), strict (the trailing slash has to match, default false) and end (default true; false also matches the paths below). |
debug | boolean | false | Print in the console which rule each visit used, or why each rule was skipped. |