Guide
Getting started
This guide adds @parallelogram-js/core to a server-rendered site: installing it, choosing import paths, starting the framework, registering components and loading their styles.
Install
npm install @parallelogram-js/core
The package is ESM only. Load it with import, or with await import() from CommonJS code. There is no require export condition.
Without a bundler
The package ships one file with everything in it, so a page can load the library straight from a CDN with no build step and a single request:
<script type="module">
import { Parallelogram, Toggle } from 'https://cdn.jsdelivr.net/npm/@parallelogram-js/core';
const app = Parallelogram.create();
app.components.add('[data-toggle]', () => Toggle);
app.run();
</script>
Pin the version once you have tested against one, as in @parallelogram-js/core@1.2.3. The URL above follows the newest release, which before 1.0 is how a minor breaks a page nobody touched. The bare specifier reaches the bundle through the package’s jsdelivr and unpkg fields; name dist/parallelogram.js yourself if you would rather be explicit.
The bundle carries every component whether the page uses one or not, so it is larger than what a bundler would produce for the same page: about 74 kB over the wire against a few kB for one or two components. In exchange there is no request waterfall, which is what the individual files give a browser that loads them directly -- each module names the next, so they arrive in rounds rather than at once.
The individual files are still there and still work without a bundler, at dist/index.js and dist/components/<Name>.js. Reach for them when a page uses one or two components and the extra requests cost less than the bytes.
It targets Baseline 2023: Chrome and Edge 120, Firefox 121, and Safari 17.2 on macOS and iOS, or later. It ships modern JavaScript without transpiling it, so a bundler only needs to resolve and bundle it. Older browsers aren't tested or supported. The versioning and security policy covers which releases may break things and which get fixes.
Import paths
Every path below works with or without .js, so @parallelogram-js/core/components/Toggle and @parallelogram-js/core/components/Toggle.js load the same file.
| Path | What it loads |
|---|---|
@parallelogram-js/core |
The root entry, described below |
@parallelogram-js/core/components/* |
One component, such as components/Toggle or components/PModal |
@parallelogram-js/core/managers/* |
One manager, such as managers/RouterManager |
@parallelogram-js/core/core/* |
One core class, such as core/BaseComponent or core/WebComponentLoader |
@parallelogram-js/core/adapters/* |
A DeferTracker adapter, such as adapters/ga4 |
@parallelogram-js/core/styles |
The package stylesheet |
@parallelogram-js/core/styles/*.css |
One component stylesheet, such as styles/toggle.css |
@parallelogram-js/core/custom-elements.json |
The Custom Elements Manifest |
The root entry exports Parallelogram, also as its default export, and ComponentRegistry, WebComponentLoader, DevLogger, BaseComponent, EventManager, RouterManager and PageManager.
import { Parallelogram, BaseComponent } from '@parallelogram-js/core';
import Toggle from '@parallelogram-js/core/components/Toggle';
Code that several paths use, such as BaseComponent, is built into shared files, so it loads once however many paths you import.
Development and production builds
Each path has two builds. The default build is minified, and a bundler that resolves the development export condition picks the development build instead. Vite resolves it during development. With esbuild, pass --conditions=development.
The production build differs from the development build in these ways:
- The framework's own
this.loggercalls todebug(),log(),info(),group()andgroupEnd()are removed. Calls towarn()anderror()stay, because they report real problems. debuggerstatements are removed and comments are dropped.- Names are minified, but class names are kept.
The production build has source maps that name the source files; the development build is readable as it is and has none. Because the production build has no debug calls, debug: true only shows the framework's debug output in the development build. Your own calls to app.logger.info() are not removed.
Start the framework
Create an instance with Parallelogram.create(), register components, then call run().
import { Parallelogram } from '@parallelogram-js/core';
const app = Parallelogram.create({
router: {},
pageManager: { containerSelector: '[data-view="main"]' },
});
app.components.add('[data-toggle]', () => import('@parallelogram-js/core/components/Toggle'));
await app.run();
Options
Parallelogram.create(config) takes these options. All are optional.
| Option | Type | Default | What it does |
|---|---|---|---|
debug |
boolean |
false |
Shows debug, log, info and group output from app.logger. The framework's own debug calls only exist in the development build |
silent |
boolean |
false |
Hides all logger output, including warnings and errors. Overrides debug |
router |
object |
null |
Router options. The router is only created when this is set, so without it links load pages normally. See Pages and the router |
pageManager |
object |
{} |
PageManager options, plus containerSelector. See Pages and the router for the options that affect navigation |
pageManager.containerSelector names the page manager's container, body by default. Components mount in it, and it is watched for added elements, only when observeRoot isn't set. The framework sets observeRoot to document.body, so components mount anywhere in the body, including a header or footer outside the container, unless you pass your own observeRoot. The router finds fragments by their data-view names anywhere in the page, whatever the container is.
Two PageManager options affect loading on every page:
| Option | Type | Default | What it does |
|---|---|---|---|
retryFailedLoads |
boolean |
true |
Retry an enhancement component whose module fails to load |
maxRetryAttempts |
number |
3 |
How many times to retry before giving up; ignored when retries are off |
run() and init()
init() creates the logger, event bus, page manager and web component loader, starts loading the router when router is set, mounts components already on the page and starts watching it. It returns the instance. Calling it a second time logs a warning and does nothing else.
run() calls init() at the right moment. If the document has finished parsing, it calls init() straight away and returns a promise that resolves with the instance. Otherwise it waits for DOMContentLoaded first. Use run() unless you know the DOM is ready, for example in a script that runs after the markup.
The promise resolves once the framework has started, not once components have loaded. Component modules load in the background. With router set, the router's code loads on demand, so pages without it don't download it, and the promise resolves once the router has loaded and started. After calling init() directly, app.router stays null until then; router:initialized is emitted on the event bus when it starts. Links followed before that load pages normally.
destroy() and isInitialized
destroy() stops the web component loader, unmounts every enhancement component, removes the router's listeners and clears every event bus listener. It does nothing when the framework hasn't started. app.isInitialized is true between init() and destroy().
Instances
After init(), the instance exposes the parts it created. They are null before then.
| Property | What it is |
|---|---|
app.eventBus |
The EventManager components and managers communicate through |
app.router |
The RouterManager once it has loaded, or null when router isn't set |
app.pageManager |
The PageManager, which mounts components through app.pageManager.host |
app.logger |
The DevLogger, which prefixes messages with [parallelogram] |
app.webComponentLoader |
The WebComponentLoader that loads web components |
app.componentRegistry |
The array of enhancement component entries: name, selector, loader and their options |
app.components exists from the start, so you can register components before run().
app.eventBus.on('page:component-load-error', ({ componentName, error }) => {
app.logger.warn(`${componentName} did not load`, error);
});
Events and alerts describes the event bus.
Register components
app.components.add() takes a tag name or a selector, and a loader or an options object. It returns app.components, so calls chain.
app.components
.add('p-modal', () => import('@parallelogram-js/core/components/PModal'))
.add('[data-modal][data-modal-target]', () => import('@parallelogram-js/core/components/Modal'))
.add('[data-toggle]', () => import('@parallelogram-js/core/components/Toggle'))
.add('[data-tabs]', {
loader: () => import('@parallelogram-js/core/components/Tabs'),
priority: 'critical',
});
Tag names and selectors
A valid custom element name, lowercase with a hyphen such as p-modal, registers a web component. Every other string is a CSS selector and registers an enhancement component, including plain element selectors such as form.
A loader returns the module, usually with a dynamic import(). It can also return the component class itself, or the module without a promise.
Enhancement component options
| Option | Type | Default | What it does |
|---|---|---|---|
loader |
function |
Loads the component, when the second argument is an options object | |
name |
string |
The selector | The name dependsOn lists and app.pageManager.instances use |
priority |
'critical' | 'normal' |
'normal' |
Critical components mount before the others on every pass |
dependsOn |
string[] |
Names of components that must load before this one's loader runs | |
exportName |
string |
The named export to use when the module has no default export |
You can also pass the loader as the second argument and the options as the third.
Registering two enhancement components with the same name throws A component named "…" is already registered. Give components that share a selector different names. A dependsOn name that isn't registered logs a warning and is ignored. A component waits while its dependencies retry; if one fails for good, the component isn't loaded either, its elements get the component-error class, and page:component-load-error is emitted for it with the dependency's error as the cause. Components that depend on each other in a cycle throw when the entry that closes the cycle is registered.
priority: 'critical' orders mounting: critical components mount before the others on every pass. After a page swap the page manager mounts critical components first and waits mountDelay for the rest, but with Parallelogram.create() the whole body is watched, so components in the new content usually mount as soon as it is added. Web components don't take options other than loader.
When components load
Nothing downloads when you register a component. An enhancement component's loader runs the first time an element matching its selector is on the page, or is added to it later. The module loads once, and every matching element found while it loads mounts when it arrives, if the element is still on the page. Elements waiting for a module have the component-loading class.
When a loader fails, it is retried after 1 second, then 2, then 4, up to maxRetryAttempts. After the last attempt the waiting elements, and matching elements added later, get the component-error class and the event bus emits page:component-load-error. A module without a component class is not retried.
To try a component that failed for good again, for example once the connection is back, call app.pageManager.host.retry(name) with its registered name. It returns false unless that component failed. Otherwise it starts a fresh set of retries, retries any failed dependencies first, and matching elements on the page wait for the new load.
app.eventBus.on('page:component-load-error', ({ componentName }) => {
window.addEventListener('online', () => app.pageManager.host.retry(componentName), {
once: true,
});
});
When the bundle itself never runs — a 404, a blocked request, a Content Security Policy that rejects it, an error thrown earlier in your own code — none of the above happens, because none of it is running. That is not the same as a loader failing, and it doesn't look the same: no element gets the component-error class, no page:component-load-error is emitted, and the stylesheets that hide content until a component mounts go on hiding it. A [data-tabs] container shows one panel, a closed [data-toggle] target stays hidden, and [data-reveal] content stays transparent, because each of those rules waits for a marker that only the framework writes. Content a visitor must be able to read should not be behind one of those rules, and a deploy is worth gating on the bundle actually loading.
Removing an element unmounts its component. Adding matching markup, whether through the router or your own script, mounts it.
Components added after run()
Components added after the framework has started are registered at once, and matching elements already on the page mount straight away.
await app.run();
app.components.add('[data-datatable]', () => import('@parallelogram-js/core/components/DataTable'));
Without the framework
An enhancement component doesn't need Parallelogram either. Each one has a static enhanceAll() that finds matching elements and mounts itself on them:
import Toggle from '@parallelogram-js/core/components/Toggle';
Toggle.enhanceAll();
It takes the selector to look for and the component's options, so Toggle.enhanceAll('[data-menu]', { capture: true }) mounts only that markup, and it returns the instance holding those elements. Call it again after adding markup to mount what's new.
What you give up is what the framework adds: a component's module loading only on the pages that use it, mounting again after a page swap, dependsOn ordering, and the shared logger and event bus. For a page with one or two enhancements and no router, that is a fair trade.
Accordion and SelectLoader are the exceptions. They are mounted by the framework only.
Web components
Web components don't need the framework. Importing a component module defines its element, and markup already on the page upgrades.
import '@parallelogram-js/core/components/PModal';
To load a web component only on pages that use it, register it with the framework:
app.components.add('p-modal', () => import('@parallelogram-js/core/components/PModal'));
The framework hands tag names to a WebComponentLoader. It loads a component when the page contains its tag, and watches the page for tags added later until every registered component has loaded. A loader whose module doesn't define the element counts as a failure, which is logged as an error.
WebComponentLoader also works on its own:
import { WebComponentLoader } from '@parallelogram-js/core';
const loader = new WebComponentLoader(
{ 'p-select': () => import('@parallelogram-js/core/components/PSelect') },
{ observeDOM: true }
);
loader.init();
| Option | Type | Default | What it does |
|---|---|---|---|
eager |
boolean |
true |
Scan the page for registered tags on init() |
observeDOM |
boolean |
false |
Watch for registered tags added later |
rootElement |
Element |
document.documentElement |
The element to scan and watch |
onLoad |
function |
Called with the tag name when a component loads | |
onError |
function |
Called with the tag name and error when a component fails | |
logger |
object |
Receives warnings and errors instead of the console |
With eager: false, call loader.loadComponent('p-select') yourself, for example on the first click of a button.
Text and translation
Every component ships with English text: the Close on a modal's close button, Clear the selection on a select, Dismiss notification on a toast. Each is a named default on the class and an attribute on the element, so there are two places to change one.
A site translates once, before the elements are on the page:
import PModal from '@parallelogram-js/core/components/PModal';
import PSelect from '@parallelogram-js/core/components/PSelect';
PModal.defaults.closeLabel = 'Fermer';
Object.assign(PSelect.defaults, { placeholder: 'Choisir…', clearLabel: 'Effacer la sélection' });
A page changes one instance with the attribute, which wins over the default:
<p-modal close-label="Fermer sans enregistrer"></p-modal>
Enhancements work the same way through defaults and their data-* options: DataTable.defaults.sortStatus is the sort every table reports, data-datatable-sort-status the one this table does. Text with {name} placeholders keeps them in whatever order the sentence needs, which is what makes a translation possible at all.
Nothing is translated for you, on purpose: a bundled translation that could not be checked would look finished and be wrong, and the French above is an example of the shape rather than a translation to ship. Dates, times, month and weekday names come from the browser's own Intl and are already in every language: <p-datetime> formats them in the language of the nearest lang, and a language without a region keeps the visitor's own conventions, so <html lang="en"> shows an Australian 15 Jan 2024 and lang="en-US" shows everyone Jan 15. Each component's page lists its text under Attributes, with the English beside it.
Styles
Web components style themselves. Their styles are bundled into each module and adopted in the shadow root, so <p-modal> looks right without a stylesheet. Adjust them with the custom properties and shadow parts each component's page lists, such as --modal-panel-bg and p-modal::part(panel).
p-modal {
--modal-panel-bg: #fffdf7;
--modal-radius: 0.25rem;
}
p-modal::part(panel)::backdrop {
background: rgb(0 0 0 / 0.6);
}
Enhancement components that need styles use a document stylesheet. Import the package stylesheet, or only the ones your pages need. A bundler that resolves package paths in CSS, such as Vite, handles these imports.
@import '@parallelogram-js/core/styles';
@import '@parallelogram-js/core/styles/toggle.css';
@import '@parallelogram-js/core/styles/tabs.css';
The component stylesheets are accordion.css, datatable.css, lazysrc.css, lightbox.css, reveal.css, tabs.css, toasts.css and toggle.css. The package stylesheet contains all of them, focus outlines for framework components, and the design tokens below. Web components inherit these tokens through their shadow roots and carry their own fallbacks, so they render without them.
Design tokens
The package stylesheet declares these custom properties on :root. Set them in your own :root rule, after the package stylesheet, to change every component that reads them. The button family styles <p-uploader>'s buttons, <p-datetime>'s action buttons and quick dates, <p-modal>'s slotted .btn buttons and DataTable's pagination; the panel family sets the padding, shadow and radius of <p-modal>'s panel, header and footer, <p-datetime>'s popover and <p-toasts>' toasts. Each component's own properties, such as --modal-btn-bg, still override the family for that component alone.
| Properties | What they set |
|---|---|
--surface-<surface>-radius, -border-width, -border-color, -color-bg, -color-text, -shadow |
Each surface: control for form fields, button, panel, dialog, dropdown, item, card and touch. Buttons have only the radius, border width and shadow; a surface declares only the properties a component reads, and the design system page lists them |
--surface-dropdown-item-hover-bg, -selected-bg, -current-bg |
Dropdown option states |
--form-control-* |
Form field padding and font size, with -sm and -lg variants, font family, placeholder colour, focus ring width and colour, focus and hover border colours, and disabled opacity and background. Borders, background and text colour follow the control surface |
--button-* |
Button padding, font size and minimum height, with -sm and -lg variants, font weight, and --button-<variant>-bg, -color, -border, -hover-bg and -hover-border for primary, secondary, danger and ghost |
--panel-* |
Panel padding, with -sm and -lg variants, and header and footer padding and borders. Background, border, radius and shadow follow the panel surface |
--framework-focus-color, --framework-focus-width, --framework-focus-offset |
Focus outlines on framework components |
--framework-transition-duration, --framework-transition-easing |
How focus outlines transition |
Colour tokens and dark mode
Colours come from a small set of roles, and the surface, form control, button and panel properties above read them. Set a role to change everything that uses it, or a surface property to change one surface.
| Role | What it colours |
|---|---|
--color-accent, --color-accent-hover |
Primary buttons, focus rings and focused field borders, and selected options |
--color-accent-contrast |
Text on the accent |
--color-text |
Text on surfaces: the page's own colour in light mode, a light grey in dark |
--color-text-muted |
Placeholders and secondary text |
--color-surface, --color-surface-muted |
Backgrounds of fields, panels, dialogs and dropdowns, and quieter areas |
--color-hover |
Tint behind hovered items |
--color-border, --color-border-strong |
Borders, and borders that need to stand out, such as cards and buttons |
--color-control-border |
Form field borders, which need 3:1 contrast to be seen |
--color-overlay, --color-shadow |
Modal backdrops and shadows |
--color-danger, --color-success, --color-warning, each with a -bg |
Status colours and their backgrounds |
--color-danger-contrast |
Text on a danger fill, such as the danger button |
--color-inverse-surface, --color-inverse-text |
Info toasts, which stand out from the page as a darker surface in both themes |
--color-success-strong, --color-warning-strong, --color-danger-strong |
Solid status fills, such as success, warning and error toasts |
--color-on-status |
Text on the solid status fills |
In light mode --color-text is currentColor, so surfaces keep inheriting the page's text colour and nothing changes for a page that sets its own. The dark theme gives it a real value, so panels, dialogs and fields carry readable text even on a page that has no dark styles of its own.
The dark theme redefines only the roles. It applies when the operating system prefers a dark colour scheme, and data-theme on <html> forces either theme: data-theme="dark" always uses it, and data-theme="light" never does. Set roles for the dark theme in the same places.
:root {
--color-accent: #7c3aed;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--color-accent: #a78bfa;
}
}
:root[data-theme='dark'] {
--color-accent: #a78bfa;
}
Set roles on :root. The surface properties read them there, so a role set on an element further down the page doesn't reach them.
The stylesheet sets color-scheme to match the theme, so the browser's own canvas, text and form controls follow it. A page that wants to decide this itself can set color-scheme after the stylesheet. The stylesheet still doesn't set the page's background and text colours, so style the page to match the theme, or let the browser's own colours show through.
Properties for one component, such as --modal-panel-bg or --toggle-transition-duration, are listed under CSS custom properties on that component's page.
Toggle's stylesheet hides closed targets and animates opening and closing, and Toggle waits for those animations. A target the markup marks data-toggle-state="closed" is hidden before Toggle mounts, but only while scripts are enabled. Tabs' stylesheet shows only the first panel until Tabs mounts, or the panel the markup marks data-tab-panel="active", and Scrollreveal's hides its elements until Scrollreveal mounts. Both show the content again if the component fails to load, but not if it was never registered. Load them for pages that use those components, and register the components wherever their stylesheets are used.
A complete page
This page has an account menu outside the swapped container, and tabs, a toggle and a modal inside it.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bookings</title>
<script type="module" src="/assets/app.js"></script>
</head>
<body>
<header>
<button type="button" data-toggle data-toggle-target="#account-menu" data-toggle-capture>
Account
</button>
<nav id="account-menu">
<a href="/bookings">Bookings</a>
<a href="/sign-out">Sign out</a>
</nav>
</header>
<main data-view="main">
<h1>Bookings</h1>
<div data-tabs>
<div data-tabs-list>
<a href="#upcoming" data-tab="upcoming">Upcoming</a>
<a href="#past" data-tab="past">Past</a>
</div>
<div data-tabs-panels>
<section id="upcoming" data-tab-panel>
<h2>Upcoming</h2>
<p>Table for four, 2 October at 7pm.</p>
<button type="button" data-modal data-modal-target="#cancel-booking">
Cancel booking
</button>
</section>
<section id="past" data-tab-panel>
<h2>Past</h2>
<p>Table for two, 14 August at 8pm.</p>
</section>
</div>
</div>
<button type="button" data-toggle data-toggle-target="#refunds">Can I get a refund?</button>
<div id="refunds">
<p>Yes, up to 48 hours before your booking.</p>
</div>
<p-modal id="cancel-booking" data-modal-size="sm">
<h2 slot="title">Cancel this booking?</h2>
<p>Your table for four on 2 October will be released.</p>
<div slot="actions">
<button type="button" data-modal-close>Keep booking</button>
<button type="button" data-modal-close>Cancel booking</button>
</div>
</p-modal>
</main>
</body>
</html>
/assets/app.js is the bundled output of this script:
import { Parallelogram } from '@parallelogram-js/core';
import './app.css';
const app = Parallelogram.create({
router: {},
pageManager: { containerSelector: '[data-view="main"]' },
});
app.components
.add('p-modal', () => import('@parallelogram-js/core/components/PModal'))
.add('[data-modal][data-modal-target]', () => import('@parallelogram-js/core/components/Modal'))
.add('[data-toggle]', () => import('@parallelogram-js/core/components/Toggle'))
.add('[data-tabs]', () => import('@parallelogram-js/core/components/Tabs'));
app.run();
And app.css imports the stylesheets the page uses:
@import '@parallelogram-js/core/styles/toggle.css';
@import '@parallelogram-js/core/styles/tabs.css';
Module scripts are deferred, so run() starts the framework once the document has been parsed. The Modal component opens the <p-modal> from its trigger, so register both. Without JavaScript, the tab links jump to their sections and the page still reads in order.
Next steps
- Pages and the router: swapping pages in place, fragments, scrolling and focus.
- Writing components: building your own enhancement components on
BaseComponent. - Events and alerts: the event bus and the events components emit.
- Upgrading from 0.5 to 0.6: removed names and what replaces them.
- Upgrading from 0.4 to 0.5: changes to imports, markup and events.
- Component pages, with attributes, events and live examples: Toggle, Tabs, Modal and
<p-modal>.