Taxi.js v2: PJAX Navigation & Page Transitions in Vanilla JavaScript

Category: Javascript | September 29, 2026
Authorcraftedbygc
Last UpdateSeptember 29, 2026
LicenseMIT
Views4 views
Taxi.js v2: PJAX Navigation & Page Transitions in Vanilla JavaScript

Taxi.js is a JavaScript PJAX navigation library for adding animated page transitions to server-rendered websites. URL-based routing can choose different transitions for specific navigation directions, with page prefetching and caching available for repeat visits.

Taxi replaces the current data-taxi-view content and exposes Renderer hooks for page-specific JavaScript. Native View Transitions, selective asset reloading, and programmatic navigation are available when a project needs them.

Features

  • PJAX navigation for same-origin links.
  • URL-based transition routing.
  • Custom Transition classes or native View Transitions.
  • Renderer lifecycle hooks for page-specific JavaScript.
  • Hover or viewport-based page prefetching.
  • Page caching with an optional LRU size limit.
  • Selective JavaScript and stylesheet reloading.
  • Programmatic navigation and browser history controls.
  • Optional page-title announcements and focus management.

Use Cases

  • Portfolio and WebGL sites that need different animations for specific source and destination URLs.
  • CMS sites with page-specific JavaScript or CSS that must load after a PJAX page swap.
  • Search and archive pages that need explicit cache refresh, bypass, or eviction controls.

How To Use It

Installation

Install the package from npm:

npm i @unseenco/taxi

For direct browser use, load Taxi.js through an ES module CDN.

<script type="module">
  import { Core } from 'https://cdn.jsdelivr.net/npm/@unseenco/taxi@2/+esm';
  const taxi = new Core();
</script>

Basic Usage

Every page needs a data-taxi wrapper and one data-taxi-view inside it. The view must be the wrapper’s only child.

After Core is created, eligible same-origin links fetch the destination document and replace the current data-taxi-view.

<main data-taxi>
  <article data-taxi-view>
    <h1>Home</h1>
    <p>Page content goes here.</p>
    <a href="/about/">About</a>
  </article>
</main>
<script type="module">
  import { Core } from 'https://cdn.jsdelivr.net/npm/@unseenco/taxi@2/+esm';
  const taxi = new Core();
</script>

Taxi leaves these links and clicks to normal browser behavior:

  • Links with data-taxi-ignore.
  • Current-page anchor links.
  • Links with a target attribute.
  • Links with a download attribute.
  • Modified clicks using Command, Control, Shift, or Alt.
  • Clicks where another handler has already called preventDefault().
<a href="/contact/">Taxi navigation</a>
<a href="/downloads/file.zip" download>
  Normal download
</a>
<a href="/checkout/" data-taxi-ignore>
  Normal page load
</a>

Use Native View Transitions

Set enableViewTransitions to true to run Taxi’s page swap through document.startViewTransition() when the browser implements the API.

Custom JavaScript Transition classes are bypassed during a native View Transition. Renderer lifecycle hooks continue to run. Browsers that lack the API use Taxi’s regular Transition behavior.

When prefers-reduced-motion: reduce is active, Taxi swaps the content with no View Transition animation. onEnterCompleted() and NAVIGATE_END wait for the native transition to finish when an animation does run.

import { Core } from '@unseenco/taxi';
const taxi = new Core({
  enableViewTransitions: true
});

Use matching view-transition-name values when an individual object should transition between two pages.

.hero-image {
  view-transition-name: hero-image;
}

Create A Custom JavaScript Transition

Extend Transition when the animation needs JavaScript control.

onLeave() receives the outgoing view as from. onEnter() receives the incoming view as to. A hook can call done() or return a Promise when its animation finishes.

import { Core, Transition } from '@unseenco/taxi';
class FadeTransition extends Transition {
  onLeave({ from }) {
    return from.animate(
      [
        { opacity: 1 },
        { opacity: 0 }
      ],
      {
        duration: 250,
        easing: 'ease'
      }
    ).finished;
  }
  onEnter({ to }) {
    return to.animate(
      [
        { opacity: 0 },
        { opacity: 1 }
      ],
      {
        duration: 250,
        easing: 'ease'
      }
    ).finished;
  }
}
const taxi = new Core({
  transitions: {
    default: FadeTransition
  }
});

Choose A Transition

Taxi checks transition sources in this order:

  1. data-transition on the clicked link.
  2. A matching URL route.
  3. The default registered Transition.

Register Transition classes under unique keys:

const taxi = new Core({
  transitions: {
    default: FadeTransition,
    gallery: GalleryTransition,
    article: ArticleTransition
  }
});

A link can request a registered Transition directly:

<a href="/gallery/" data-transition="gallery">
  Open gallery
</a>

Route Transitions By URL

addRoute() selects a Transition from the current pathname and destination pathname.

Taxi anchors each route expression to the entire pathname and removes trailing slashes before matching. The homepage therefore uses an empty string. Rules run in declaration order, so put specific routes before catch-all expressions.

taxi.addRoute('/blog/.*', '', 'blogToHome');
taxi.addRoute('', '/blog/.*', 'homeToBlog');
taxi.addRoute('/projects/.*', '/projects/.*', 'projectToProject');

A specific rule should appear before a wider expression:

taxi.addRoute('/projects/featured', '', 'featuredToHome');
taxi.addRoute('/projects/.*', '', 'projectToHome');

Run Page-Specific JavaScript With Renderers

A Renderer manages JavaScript associated with a page type. Put the registered Renderer key in data-taxi-view.

The base initialLoad() method does not call onEnter() or onEnterCompleted() in the current release. Call those hooks from a custom initialLoad() when the first page visit needs the regular enter setup.

import { Core, Renderer } from '@unseenco/taxi';
class ArticleRenderer extends Renderer {
  initialLoad() {
    this.onEnter();
    this.onEnterCompleted();
  }
  onEnter() {
    initArticleWidgets();
  }
  onLeave() {
    destroyArticleWidgets();
  }
}
const taxi = new Core({
  renderers: {
    default: Renderer,
    article: ArticleRenderer
  }
});
<main data-taxi>
  <article data-taxi-view="article">
    ...
  </article>
</main>

Renderer Lifecycle Hooks

HookRuns When
initialLoad()The site first loads with this Renderer active.
onLeave()The current page begins leaving.
onLeaveCompleted()The leave Transition has completed.
onEnter()The incoming content has entered the Taxi wrapper.
onEnterCompleted()The enter Transition has completed.

Renderer Properties

PropertyDescription
this.pageFull document associated with the Renderer.
this.titleDocument title for the rendered page.
this.wrapperMain data-taxi wrapper.
this.contentActive data-taxi-view.
this.triggerNavigation trigger, including a link element, 'popstate', 'initialLoad', or false.

Transition Hooks

this.wrapper inside a Transition references the main data-taxi wrapper.

HookParameters
onLeave(){ from, trigger, done }
onEnter(){ to, trigger, done }

Prefetch Pages

Taxi prefetches links on mouseenter and focus by default.

Set enablePrefetch to 'visible' to preload matching same-origin links when they enter the viewport. Visible prefetching does not run when navigator.connection.saveData reports Data Saver mode.

const taxi = new Core({
  enablePrefetch: 'visible'
});

Disable automatic prefetching with:

const taxi = new Core({
  enablePrefetch: false
});

preload(url, preloadAssets) can prime a URL manually. Pass true as the second argument when Taxi should create the incoming DOM early so referenced assets can begin loading.

taxi.preload('/gallery/');
taxi.preload('/gallery/', true);

preload() resolves with the resulting CacheEntry and rejects after a failed request or a fetched page with no data-taxi-view.

taxi.preload('/gallery/')
  .then((entry) => {
    console.log(entry.title);
  })
  .catch((error) => {
    console.error(error);
  });

Control The Page Cache

Fetched pages enter Taxi’s cache by default.

maxCacheSize sets a positive cache limit and evicts the least recently used non-current entry. The default 0 leaves the cache size unlimited.

const taxi = new Core({
  maxCacheSize: 10
});

Set bypassCache to disable the page cache for the entire Core instance:

const taxi = new Core({
  bypassCache: true
});

Place data-taxi-nocache on a view that should always be fetched again:

<article data-taxi-view data-taxi-nocache>
  ...
</article>

Use updateCache() after dynamic DOM changes that need to become the current cached state. Both cache methods accept an optional URL.

taxi.updateCache();
taxi.updateCache('/search/');
taxi.clearCache();
taxi.clearCache('/search/');

Reload Page-Specific JavaScript And CSS

Taxi processes scripts, stylesheets, and inline styles marked with data-taxi-reload by default.

<script src="/js/gallery.js" data-taxi-reload></script>
<link
  rel="stylesheet"
  href="/css/gallery.css"
  data-taxi-reload
>

Set reloadJsFilter or reloadCssFilter to a custom predicate when the attribute rule is not suitable. Set either option to false to disable that asset type.

const taxi = new Core({
  reloadJsFilter: (element) => {
    return element.dataset.taxiReload !== undefined ||
      element.src?.includes('gallery.js');
  },
  reloadCssFilter: (element) => {
    return element.dataset.taxiReload !== undefined;
  }
});

Enable Navigation Accessibility Handling

Set enableAccessibility to true when PJAX navigation should announce the destination title and restore useful keyboard focus after each page swap.

Taxi creates an assertive aria-live region for the page title. Focus moves to the first <h1> inside the incoming view, or to data-taxi-view when no <h1> exists. Taxi sets tabindex="-1" when the focus destination does not already have a tabindex.

const taxi = new Core({
  enableAccessibility: true
});

Listen For Navigation Events

All three events receive a payload containing from, to, and trigger.

For NAVIGATE_OUT, the destination to value can be a stub when Taxi has not cached the destination yet. In that state, finalUrl is available and the other destination fields can be null.

EventFires When
NAVIGATE_OUTNavigation begins before the outgoing Transition runs.
NAVIGATE_INThe incoming view has entered the document.
NAVIGATE_ENDThe incoming Transition has completed.
taxi.on('NAVIGATE_OUT', ({ from, to, trigger }) => {
  console.log('Leaving:', from.finalUrl);
});
taxi.on('NAVIGATE_IN', ({ to }) => {
  console.log('New view:', to.finalUrl);
});
taxi.on('NAVIGATE_END', ({ to }) => {
  console.log('Finished:', to.finalUrl);
});

Pass the original callback to off() to remove one listener. Omit the callback to remove every listener registered for that event.

function handleNavigation(data) {
  console.log(data.to.finalUrl);
}
taxi.on('NAVIGATE_END', handleNavigation);
taxi.off('NAVIGATE_END', handleNavigation);
taxi.off('NAVIGATE_IN');

Programmatic Navigation

navigateTo() accepts the destination URL, an optional registered Transition key, and an optional trigger value.

taxi.navigateTo('/contact/');
taxi.navigateTo('/contact/', 'article');
taxi.navigateTo('/contact/', 'article', 'menu');

Taxi exposes guarded browser history methods too:

taxi.navigateBack();
taxi.navigateForward();

Configuration Options

  • renderers (Record<string, Renderer>, default { default: Renderer }): Registers Renderer classes by key.
  • transitions (Record<string, Transition>, default { default: Transition }): Registers Transition classes by key.
  • links (string, default "a[href]:not([target]):not([href^=\\#]):not([data-taxi-ignore])"): CSS selector used for delegated navigation links.
  • removeOldContent (boolean, default true): Removes the outgoing view after its leave Transition.
  • allowInterruption (boolean, default false): Lets a new navigation interrupt the active navigation.
  • bypassCache (boolean, default false): Disables Taxi’s page cache.
  • enablePrefetch (false | 'hover' | 'visible', default 'hover'): Selects the automatic prefetch mode. The legacy boolean value true maps to 'hover'.
  • enableViewTransitions (boolean, default false): Uses document.startViewTransition() when the browser has the API.
  • enableAccessibility (boolean, default false): Announces the new page title and moves focus after navigation.
  • maxCacheSize (number, default 0): Limits cached pages with least-recently-used eviction. 0 leaves the cache unlimited.
  • fetchOptions (RequestInit, default {}): Applies Fetch API options to Taxi requests.
  • reloadJsFilter (boolean | function): Selects incoming scripts for execution. The default predicate matches data-taxi-reload.
  • reloadCssFilter (boolean | function): Selects incoming <link rel="stylesheet"> and <style> nodes for processing. The default predicate matches data-taxi-reload.

Core API Methods

MethodDescription
addRoute(fromPattern, toPattern, transition)Registers a URL-based Transition rule.
navigateTo(url, transition?, trigger?)Runs programmatic navigation and returns Promise<void>.
navigateBack()Calls browser back navigation after checking the active-transition guard.
navigateForward()Calls browser forward navigation after checking the active-transition guard.
preload(url, preloadAssets?)Fetches a page into the cache and resolves with its CacheEntry.
updateCache(url?)Rebuilds cached HTML for a URL or the current page.
clearCache(url?)Removes a URL or the current page from the cache.
destroy()Removes Taxi’s navigation listeners and observers, aborts its active request, removes the accessibility announcer, and clears its cache.
setDefaultRenderer(renderer)Changes the default registered Renderer.
setDefaultTransition(transition)Changes the default registered Transition.
on(event, callback)Registers a Taxi navigation event listener.
off(event, callback?)Removes one callback or all callbacks for an event.

Callbacks registered through taxi.on() are not removed by destroy(). Remove those listeners with taxi.off() when the surrounding application lifecycle requires cleanup.

`currentCacheEntry`

currentCacheEntry is a read-only getter for the active page’s CacheEntry.

A CacheEntry uses these fields:

FieldDescription
rendererRenderer instance associated with the page.
pageSource Document or Node.
scriptsReloadable script elements collected from the page.
stylesReloadable stylesheet and style elements collected from the page.
finalUrlFinal resolved page URL.
skipCacheIndicates that the page has data-taxi-nocache.
titleDocument title.
contentPage’s data-taxi-view element.

Public Data Attributes

AttributePurpose
data-taxiMarks the persistent wrapper around replaceable page content.
data-taxi-viewMarks replaceable page content. Its value can select a registered Renderer.
data-taxi-ignoreExcludes a link from Taxi navigation.
data-transitionSelects a registered Transition for a link click.
data-taxi-nocachePrevents a page view from being reused from Taxi’s cache.
data-taxi-reloadMarks a script, stylesheet, or inline style for processing after navigation.

Alternatives & Related Resources

FAQs

Q: Why does JavaScript or CSS from the next page not run after navigation?
A: Taxi processes incoming scripts and styles marked with data-taxi-reload by default. Change reloadJsFilter or reloadCssFilter when the project needs different asset rules.

Q: Can Taxi.js be imported by code that also runs during server-side rendering?
A: Yes. Importing the package no longer requires a DOM. Create the Core instance in the browser because initialization needs document, navigation APIs, and a data-taxi wrapper.

Q: Why does a link perform a normal page load?
A: Check for data-taxi-ignore, target, download, current-page hash URLs, modified clicks, cross-origin destinations, or an earlier handler that called preventDefault().

Q: Does Taxi.js manage keyboard focus after a PJAX page change?
A: Set enableAccessibility: true. Taxi announces the destination title and focuses the incoming page’s first <h1>, or the view itself when no <h1> exists.

You Might Be Interested In:


Leave a Reply