I’ve been building a small JavaScript framework called intly.js. It brings together the things I keep needing across website projects: loading content, calling APIs, remembering state and making interfaces respond without reloading the whole page.
It has grown out of real work and become a shared foundation across many of the websites I build. A menu, a gallery, a set of filters or a popup can be quite simple on its own. Across a larger website, those pieces need a consistent way to load, update and share information. I wanted a foundation I could understand fully and reuse.
Starting with small functions
Learning about functional programming through tutorials made me look more closely at how I was structuring JavaScript. I liked the idea of small functions with clear inputs, and of being able to combine them without having to understand an entire application first.
That influenced Intly, although I wouldn’t call it a purely functional framework. It changes the DOM, mutates state and writes to storage. Those are side effects. The useful lesson for me was to make the responsibilities obvious: work out what needs to change, update the state, then let a rendering function reflect it on the page.
The core is split into ES modules. There is a small DOM helper, a cache, a REST wrapper, a page loader and separate helpers for toggles, state-driven content and accessibility attributes. Imports make those dependencies visible. Someone opening the page loader can see that it uses the DOM helper and cache, without having to trace a large shared script.
Even the selector helper is straightforward:
const lib = {
qsa: (selector, root = document) => {
return [...root.querySelectorAll(selector)];
},
qs: (selector, root = document) => {
return root.querySelector(selector);
}
};
export { lib as default };
Returning an array lets me work with a collection using familiar methods. Passing in a root means the same helper can work within a particular component or a fetched document. There is very little to learn before it becomes useful.
Loading the next page without starting again
The page-loading side is handled by a module called pager. A designated wrapper holds the view, and selected internal links tell the pager which navigation it should handle. The browser fetches the next page as HTML, DOMParser reads the response, and the matching content is placed into a temporary view alongside the existing one.
CSS classes handle the incoming and outgoing transitions. The old view is removed afterwards, the document title and address are updated, and a callback runs the page-specific behaviour. The header and shared assets can stay in place while the content changes.
This is how I initialise that part of the framework:
// The page contains #view-main > div.
// Selected internal links carry a view-main attribute.
pager.init('view-main', App.scope, false);
The last argument controls page caching. The callback, App.scope, is where I decide what the newly loaded content needs. A homepage effect can check the current path; a gallery can initialise against the elements that are actually present.
There are important details behind a smooth transition. New content needs its own event handling, and behaviour attached to old elements needs cleaning up. Scripts embedded in fetched HTML do not simply execute as though the browser had loaded a new document. Keeping that work in an explicit page callback makes it easier to follow.
The early pager uses history.replaceState(). That updates the current history entry; it does not create a new one for each visit. Proper Back and Forward navigation needs more work, including handling popstate. I see that as part of the page-loader’s responsibilities, alongside focus, loading errors and a normal navigation fallback.
WordPress APIs and custom filters
WordPress still does the server-side work. Intly provides the browser side of the interaction. The resting module fetches JSON, optionally reads a cached response, and passes the result to a callback. It also accepts an array of keys when I only need a nested part of the response.
A category filter can make a request like this. The category ID is just an example, and renderPosts is the project’s own rendering function:
const loadCategory = (categoryId) => {
const query = new URLSearchParams({
categories: String(categoryId),
per_page: '6'
});
resting.fetch(
`/wp-json/wp/v2/posts?${query}`,
[],
renderPosts,
false
);
};
loadCategory(7);
The selected category changes the request, while the rendering callback stays the same. A bespoke WordPress endpoint can return a different content type or a more specific result. The early framework includes a PHP example of registering a search route and adding a featured-image field to REST responses.
Third-party integrations fit around the same separation. The original REST helper prefixes requests with the website’s own origin, so it isn’t an arbitrary external-URL client. For a service that needs private credentials, WordPress can call the provider on the server and expose an appropriate response through a site endpoint. Intly then works with that response. A public external API could use a separate fetch helper, provided the provider allows the browser request through CORS.
Keeping the request and display logic separate gives me room to add a loading state, an empty result and an error message without rewriting the filters. The early wrapper needs additional error handling: fetch does not reject just because a server returns a 404 or 500. Status checks and protection against an older response overwriting a newer filter selection belong in a finished implementation.
Caching and remembering state
Caching is built into the REST wrapper and pager through a shared module called cacher. Its basic job is to serialise a value into localStorage and read it back:
const cacher = {
store: (name, state) => {
localStorage.setItem(name, JSON.stringify(state));
},
retrieve: (name) => {
return JSON.parse(localStorage.getItem(name));
}
};
export { cacher as default };
With caching enabled, a previously stored response can be reused instead of making the same network request again. The same helper can remember a small preference between visits. That is separate from the browser’s normal HTTP cache: this is application data stored under a key I control.
The initial version is deliberately basic. It has no built-in expiry or versioning, so I need to decide what is safe to cache and when to clear it. Fresh results, private information and rapidly changing data need different treatment from a display preference. Storage can also be unavailable or contain an invalid value, so a robust version needs to fail back to the request or default state.
During a visit, I use a shared state object for values that several modules need. The early application declares it in the entry module and passes it into the relevant functions. That gives it an application-wide role without requiring every value to be attached to window. localStorage is for the parts I want to persist, rather than every change in the interface.
Web Storage is synchronous, which matters when talking about performance. A small preference is one thing; serialising a large response repeatedly is another. I want storage writes to happen when they are useful, rather than on every animation frame.
Toggles, sliders, galleries and popups
I’ve used the same approach to build sliders, galleries and custom popups. An active slide, an open panel or a selected option is state. JavaScript changes that state and updates the relevant attributes or classes; CSS handles the visual result.
The toggle helper accepts a name, the current value and two possible options. It applies the selected value to matching elements and can call another function after a change. Here is a shortened usage example for a gallery panel, using a native button:
const panelState = {
name: 'data-gallery',
current: 'closed',
opt: ['closed', 'open'],
body: false
};
const trigger = lib.qs('#gallery-toggle');
trigger.setAttribute('aria-expanded', 'false');
toggler.toggle(trigger, panelState, (nextState) => {
trigger.setAttribute(
'aria-expanded',
String(nextState.current === 'open')
);
});
// CSS can target:
// #gallery-panel[data-gallery="closed"] { display: none; }
The button needs aria-controls="gallery-panel", and the panel carries id="gallery-panel" and data-gallery="closed". That is a small disclosure example, not a complete modal. A popup that behaves as a dialog also needs appropriate keyboard handling, focus management and a way to close it.
For a slider, I can keep the calculation of the next index separate from moving the track. For a gallery, the selected image can be separate from opening the viewer. That separation is what I took from the functional-programming tutorials. It makes the behaviour easier to reason about and gives another developer a smaller piece of code to work on.
An eco mode with fewer assets
One of the uses I’m particularly interested in is building eco websites. The early application has an eco state which changes how the site loads images and whether the animated pager and scroll effects are enabled.
Image URLs are held in data-src until the chosen mode assigns them to src. The standard mode uses the high-resolution images; the eco mode uses a separate low-resolution set. Those smaller assets can be prepared in black and white. The saving comes from delivering a smaller file and avoiding unnecessary requests, rather than applying a grayscale filter to an image that has already downloaded.
That gives me a practical way to keep the message and useful content while reducing some of the heavier presentation. Disabling an effect also needs to stop its work, not just hide the result. Likewise, an image shouldn’t already have a high-resolution src or srcset if the intention is to avoid that download.
The ES module files in the early application are statically imported. Splitting code into modules helps organisation, but does not automatically make it load on demand. The gains here come from keeping the shared code small, limiting what it does, controlling media requests and reusing assets that are already loaded during a page transition.
Aiming for 100/100 performance
I want this foundation to support websites capable of a 100/100 Lighthouse performance score, while still giving me the interactions a design needs. Starting with a small amount of JavaScript makes that easier, but the score belongs to the whole page: its server response, CSS, fonts, images, third-party scripts and the amount of work happening in the browser.
Lighthouse scores depend on the measured loading metrics, and results can vary between runs and conditions. A quick cached transition is useful, but it does not prove that a first visit on a slower connection will perform equally well. I need to measure both.
The aim is very little overhead. It would be wrong to describe that as no overhead: fetching, parsing HTML, querying the DOM and writing to storage all cost something. I keep coming back to whether the work earns its place on the page, and whether the browser is doing it more often than necessary.
Building Intly has helped me understand those decisions at a much closer level. It gives me a common way to build dynamic websites, and a codebase where I can follow a filter change from the click, through the request, into the state and back to the interface. That clarity is a large part of why I wanted to build it.
Code examples are shortened or adapted from my early 2022 Intly implementation. They explain the approach rather than provide a complete installation. The source repository is private.