pjax-max
v4.0.0
Published
Lightweight PJAX library for page transitions
Maintainers
Readme
pjax-max
A lightweight (~2.3KB gzipped), zero-dependency PJAX library for page transitions. Navigate between pages without full reloads, with full control over transition animations.
Built on top of the browser's native History API, Fetch API, and EventTarget.
Install
npm install pjax-maximport { Core, Renderer, Transition } from 'pjax-max';Or via CDN:
<script src="https://unpkg.com/pjax-max"></script>
<script>
const { Core, Renderer, Transition } = PjaxMax;
</script>HTML Setup
pjax-max requires two data attributes in your markup:
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<main data-router-wrapper>
<article data-router-view="home">
<!-- page content -->
</article>
</main>
</body>data-router-wrapper— The container that holds views. Stays in the DOM across navigations.data-router-view="slug"— The swappable view element. The slug maps to your renderers.
Quick Start
import { Core, Renderer, Transition } from 'pjax-max';
// 1. Define a transition
class Fade extends Transition {
out({ from, done }) {
gsap.to(from, {
opacity: 0,
duration: 0.3,
force3D: true,
ease: 'sine.out',
onComplete: () => {
from.remove();
done();
}
});
}
in({ to, done }) {
gsap.fromTo(to,
{ opacity: 0 },
{ opacity: 1, duration: 0.3, force3D: true, ease: 'sine.out', onComplete: () => { done } }
);
}
}
// 2. Initialize
const core = new Core({
transitions: {
default: Fade
}
});That's it. All internal links will now use PJAX navigation with your fade transition.
Core
Constructor Options
const core = new Core({
renderers: { ... }, // Map of slug -> Renderer class
transitions: { ... }, // 'default' transition, contextual overrides, and optional _popstate
onLeave: [ ... ], // Functions called at the start of every out transition
onEnter: [ ... ], // Functions called at the start of every in transition
pageEntrance: { ... }, // Functions called after the in transition completes, keyed by namespace
});Properties
| Property | Type | Description |
|---|---|---|
| core.transitioning | boolean | true while a navigation is in progress |
| core.namespace | string | The current view's data-router-view slug |
| core.cache | Map | Page cache (URL -> properties) |
Methods
| Method | Description |
|---|---|
| core.redirect(url, contextual?, trigger?) | Navigate to a URL programmatically |
| core.attach(links) | Attach click listeners to links |
| core.detach(links) | Remove click listeners from links |
| core.precache(url, html) | Store pre-fetched HTML in the cache without triggering a fetch |
Events
Listen to navigation lifecycle events using the native addEventListener API:
core.addEventListener('NAVIGATE_OUT', (e) => {
const { from, trigger, location } = e.detail;
});
core.addEventListener('NAVIGATE_IN', (e) => {
const { to, trigger, location } = e.detail;
});
core.addEventListener('NAVIGATE_END', (e) => {
const { from, to, trigger, location } = e.detail;
});Navigation Lifecycle Hooks
The onLeave and onEnter arrays let you run functions on every navigation without repeating logic in each transition. Common uses: closing menus, destroying scroll instances, resetting UI state.
const core = new Core({
transitions: { default: Fade },
onLeave: [
() => scroll?.destroy(),
() => megaNav.isOpen && megaNav.close(),
() => mobileMenu.isOpen && mobileMenu.close(),
],
onEnter: [
() => console.log('Entering:', core.namespace),
],
});onLeave runs before the out transition. onEnter runs after the new view is in the DOM but before the in transition plays. core.namespace is updated before onEnter fires, so it reflects the incoming page.
Page Entrance
The pageEntrance option lets you run functions after the in transition completes, keyed by namespace. This is useful for per-page entrance animations (e.g., staggering elements in with GSAP). A default key acts as a fallback for any namespace without a specific entry.
const core = new Core({
transitions: { default: Fade },
pageEntrance: {
default: ({ view }) => {
gsap.fromTo(view.querySelectorAll('[data-animate]'),
{ y: 20, opacity: 0 },
{ y: 0, opacity: 1, stagger: 0.1 }
);
},
home: ({ view }) => {
gsap.fromTo(view.querySelector('.hero'),
{ scale: 0.95, opacity: 0 },
{ scale: 1, opacity: 1, duration: 0.6 }
);
},
}
});Each function receives { view, from, trigger, location }:
| Property | Description |
|---|---|
| view | The incoming view element (data-router-view) |
| from | The previous view element, or null on initial page load |
| trigger | The link element that triggered navigation, 'popstate', 'script', or null on initial load |
| location | The current location object |
Page entrance also fires on the initial page load (after the initial renderer sets up), so your entrance animations run on first visit too.
Renderers
Renderers control page-specific setup and teardown. Extend the base Renderer class and override lifecycle hooks:
class HomeRenderer extends Renderer {
onEnter() {
// Called when the view is added to the DOM
}
onEnterCompleted() {
// Called after the in transition completes
}
onLeave() {
// Called before the out transition starts
}
onLeaveCompleted() {
// Called after the view is removed
}
}Map renderers to view slugs:
const core = new Core({
renderers: {
home: HomeRenderer,
about: AboutRenderer,
},
transitions: { default: Fade }
});Renderers support dynamic imports for code splitting:
const core = new Core({
renderers: {
home: () => import('./renderers/home.js'),
}
});Transitions
Transitions control the animation between views. Extend the base Transition class and implement in and out:
class Fade extends Transition {
out({ from, trigger, done }) {
// Animate the old view out, then call done()
gsap.to(from, {
opacity: 0,
duration: 0.4,
onComplete: () => {
from.remove();
done();
}
});
}
in({ to, from, trigger, done }) {
// Animate the new view in, then call done()
gsap.fromTo(to,
{ opacity: 0 },
{ opacity: 1, duration: 0.4, onComplete: done }
);
}
}Default vs. Pathname vs. Contextual Transitions
A default transition applies to all navigations. Pathname-based transitions let you assign transitions to specific destination pages by matching on the URL pathname:
const core = new Core({
transitions: {
default: Fade,
'_home': FadeIn, // used when navigating to /
'about': SlideUp, // used when navigating to /about
'work': CrossFade, // used when navigating to /work
'work/project': ZoomIn, // used when navigating to /work/project
slideUp: SlideUp, // available as a contextual override
}
});The pathname is cleaned of leading/trailing slashes before lookup (e.g., /about/ → about). The root path / maps to _home. The match is exact — /work/project-name will not fall back to work.
Keys starting with _ are reserved for the library (_home, _popstate).
Use contextual transitions on specific links with the data-transition attribute to override both pathname-based and default transitions:
<a href="/gallery" data-transition="slideUp">Gallery</a>When this link is clicked, SlideUp is used regardless of what pathname-based or default transition would apply.
Priority (highest to lowest):
data-transitionattribute on the link (click navigation only)- Pathname-based match in the transitions map
_popstatedirection transition (back/forward only)defaulttransition
With _popstate.priority: true, items 2 and 3 swap — popstate transitions win over pathname matches. See Popstate Transitions below.
Popstate Transitions (Back/Forward)
By default, browser back/forward navigations use the default transition. The _popstate reserved keyword lets you specify different transitions for each direction:
const core = new Core({
transitions: {
default: Fade,
_popstate: {
back: SlideRight,
forward: SlideLeft,
}
}
});Both back and forward are optional — specify one, both, or neither. If a direction is not specified, the default transition is used. If neither is specified, _popstate is ignored entirely.
pjax-max tracks history position internally to detect whether a popstate event is a back or forward navigation, since the browser does not expose this.
Popstate Priority
By default, pathname-based transitions take precedence over popstate transitions. If you have both about: SlideUp and _popstate.back: SlideRight, pressing back to /about will use SlideUp.
Set priority: true to make popstate transitions win over pathname matches:
const core = new Core({
transitions: {
default: Fade,
about: SlideUp,
_popstate: {
back: SlideRight,
forward: SlideLeft,
priority: true, // popstate transitions win over pathname-assigned transitions
}
}
});With priority: true, pressing back to /about uses SlideRight instead of SlideUp. Defaults to false.
Overlapping Transitions
The old view stays in the DOM until you explicitly remove it. This means you can run both views simultaneously for overlapping transitions:
class CrossFade extends Transition {
out({ from, done }) {
gsap.to(from, { opacity: 0, duration: 0.6, onComplete: done });
// Don't call from.remove() here — let the in transition overlap
}
in({ to, from, trigger, done }) {
from.remove(); // Remove old view when ready
gsap.fromTo(to,
{ opacity: 0 },
{ opacity: 1, duration: 0.6, onComplete: done }
);
}
}Scroll Restoration
By default, pjax-max lets the browser handle scroll position naturally. If you want to always start at the top of the page after navigation, enable manualScrollRestoration:
const core = new Core({
transitions: { default: Fade },
manualScrollRestoration: true
});When enabled, this sets history.scrollRestoration = 'manual' and scrolls to the top instantly after the old view is removed and the new view is added to the DOM — before the in transition plays.
Defaults to false.
Event Mask
During transitions, user interactions like scrolling, clicking, or swiping can cause visual glitches. pjax-max automatically creates an invisible full-screen overlay that blocks these gestures while a transition is in progress.
The mask is appended to the <body> on initialization with pointer-events: none. When a navigation starts it switches to pointer-events: auto, capturing and preventing scroll, wheel, and touchmove events. When the transition completes it switches back to none.
No setup required — this is fully automatic.
Precaching
pjax-max does not prefetch pages on its own. If your host environment already has HTML for a page (for example, from a CDN warm-up or a client-side prefetch strategy), hand it to pjax-max via core.precache(url, html) so navigation to that URL skips the network entirely.
const response = await fetch('/about');
const html = await response.text();
core.precache('/about', html);Signature
core.precache(url: string, html: string): booleanurl— Absolute or relative URL. Resolved againstwindow.location.href.html— Full HTML string for the page at that URL.- Returns
trueif the entry was added to the cache,falseotherwise.
Behavior
- Same-origin only. Cross-origin URLs are rejected and return
false. - Dedupes by URL. If the resolved URL is already in
core.cache,precachereturnsfalseand does not overwrite the existing entry. - No fetch. The HTML is parsed and stored directly — no network request is made.
- Invalid input safe. Non-string arguments or unparseable URLs return
falseinstead of throwing.
When a navigation targets a precached URL, the out transition still runs as normal, but the new view is rendered from cache without waiting on the network.
Disabling PJAX on Links
Add data-router-disabled to opt out of PJAX navigation:
<a href="/external" data-router-disabled>Normal navigation</a>Links with a target attribute (e.g., target="_blank") are also ignored automatically. Cmd/Ctrl+click opens links in a new tab as expected.
Browser Support
All modern browsers. No IE support.
- Chrome / Edge
- Firefox
- Safari
