@marcomattes/epaper-components
v2.0.0
Published
E-Paper (e-ink) web component library — vanilla custom elements with design tokens. Zero runtime dependencies, framework-agnostic.
Maintainers
Readme
EPaper
EPaper is a component library of plain custom elements for user interfaces that
run on electrophoretic (e-paper, e-ink) displays. It ships 108 registered elements, a three-layer
CSS token system, strict TypeScript types and a Custom Elements Manifest. There
is no framework dependency and no runtime dependency at all; components extend
EpaperElement — a thin HTMLElement subclass that stands in for it where
there is no DOM, so the package imports cleanly in Node — or the shared
BaseFormControl, and render into the light DOM.
Design constraints
Electrophoretic display controllers (EPDCs) drive pixels through a multi-step waveform rather than refreshing a backlit matrix at a fixed frame rate. Full panel refreshes take roughly 200–800 ms and are visible as a flash, partial refreshes of a dirty rectangle take roughly 30–80 ms, and intermediate pixel states persist as ghosting until a subsequent refresh clears them. Panels are reflective, typically run at 150–300 ppi without sub-pixel anti-aliasing, and render either in grayscale (Carta) or in a five-color gamut (Kaleido).
Those properties rule out a number of techniques that are unremarkable on emissive displays, and the library is designed around that rather than around a visual style. The sections below document each decision together with the hardware behaviour it follows from.
Animations and transitions
CSS transitions and animations produce intermediate pixel states that the waveform cannot resolve cleanly, so they leave ghosting that survives several refresh cycles. Both are therefore disabled globally in the base reset:
/* src/styles/base.css */
.ink-page,
.ink-page *,
.ink-page *::before,
.ink-page *::after {
transition: none !important;
animation: none !important;
}The source tree contains no @keyframes rules, and state changes are applied as
direct DOM mutations inside event handlers rather than through an animation
pipeline. Two components use requestAnimationFrame, both to coalesce
high-frequency input into a single write per frame rather than to animate
anything: <e-splitter> batches pointer-drag updates, and <e-anchor> batches
scroll-position updates. Without that coalescing each pointer or scroll event
would trigger its own partial refresh.
Interaction states
Capacitive touch layers on e-paper hardware report contact, not proximity, so
there is no hover state to style; the library contains no :hover rule.
Opacity values and mid-tone greys are also avoided for state changes, because
partial-tone pixels dither inconsistently between refreshes and the same element
can end up rendering differently after a redraw. Interaction state is encoded in
primitives that survive 1-bit rendering:
| State | Encoding |
| -------- | ------------------------------------------------------------ |
| Focus | 3px solid --ink-fg outline + 2px offset (:focus-visible) |
| Pressed | Foreground/background inversion on :active |
| Selected | [aria-selected] / [aria-checked] + flat fill flip |
| Disabled | Diagonal hatch fill via --ink-hatch-disabled |
| Error | 3px border + --ink-hatch-error fill |
The hatch patterns are defined once in src/styles/tokens.css and reused across every control, so a disabled control reads as disabled at any refresh depth:
--ink-hatch-disabled: repeating-linear-gradient(45deg, #000 0 1px, transparent 1px 4px);
--ink-hatch-error: repeating-linear-gradient(45deg, #000 0 1px, transparent 1px 5px);
--ink-hatch-cover: repeating-linear-gradient(45deg, #fff 0 2px, #000 2px 6px);Selection inverts the two ink colors (background: var(--ink-fg); color: var(--ink-bg))
instead of tinting, for the same reason.
DOM updates
Because the EPDC tracks dirty rectangles, the cost of an update scales with the area that changed. Replacing a subtree marks a large region dirty and typically forces a full refresh; mutating a single attribute or text node stays within a partial refresh.
Components therefore render once in connectedCallback, guarded by a _wired
flag against double initialisation, and afterwards mutate through four typed
patch helpers in src/core/dom.ts. Each helper compares the
incoming value against the current one and returns early when they match:
patchText(node, value); // textContent, only on diff
patchAttr(el, name, value); // setAttribute / removeAttribute, only on diff
patchBoolAttr(el, name, on); // boolean attribute toggle, only on diff
patchClassModifier(el, prefix, mod); // BEM-style modifier swap, only on diffThe practical effect is that an attributeChangedCallback routed through
patchAttr costs nothing when a framework re-asserts a value it already set,
which is a common pattern in reactive renderers and would otherwise cause a
refresh per render pass.
Not every update goes through the patch helpers, though. Components whose
content is structural rather than a single value — <e-table> rebuilding its
rows, <e-calendar> rebuilding its grid, <e-select> rebuilding its option
list — call an internal _build() that replaces the whole subtree on a
relevant data change, the same tradeoff a bare innerHTML re-render would
make. That subtree is still bounded to the one component rather than the
page, but it is a full-subtree refresh, not a surgical patch.
Light DOM
No component calls attachShadow; everything renders into the light tree via
this.innerHTML, with esc() applied to interpolated values. There are three
reasons, and all of them are specific to the deployment targets this library
expects:
- Native form participation. Form-associated custom elements need the
<form>to reachnameandvalue. Shadow DOM complicates theFormDatawalk and requires additional focus-delegation handling. - Restricted runtimes. E-paper kiosks and readers frequently ship stripped or outdated browser builds in which Shadow DOM support is incomplete.
- Overridable styling. The rendered tree is inspectable and can be
restyled with ordinary selectors, without exposing a
::partsurface for every internal node.
Form-associated controls
Thirteen interactive components extend BaseFormControl
(src/core/base-form-control.ts), which sets
static formAssociated = true and wires ElementInternals so that the control
participates in FormData, form.reset() and constraint validation without
additional JavaScript:
<e-input>,<e-textarea>,<e-checkbox>,<e-checkbox-group>,<e-radio-group>,<e-toggle>,<e-select>,<e-cascader>,<e-tree-select>,<e-input-number>,<e-date-picker>,<e-time-picker>,<e-upload>.
<e-button> is form-associated separately so that it can submit or reset its
parent form natively. Together this allows a form to be built and submitted from
plain HTML, which matters for kiosk deployments that ship no application
framework.
Stroke weights and icon geometry
At 150–300 ppi without sub-pixel anti-aliasing, 1px strokes lose contrast against the reflective substrate and can disappear entirely after a partial refresh. The token layer sets minimum weights accordingly:
--ink-border-width: 2px; /* default */
--ink-border-width-strong: 4px; /* emphasis */
--ink-border-width-hair: 1px; /* only for inert dividers */
--ink-border-width-error: 3px; /* validation */
--ink-focus-width: 3px; /* focus ring */The 65 icons in src/core/icons.ts are rendered as stroked
paths with stroke-width="2" fill="none" stroke-linecap="square" stroke-linejoin="miter".
Filled shapes are avoided below 32px because they dither visibly on Kaleido
panels, and square caps avoid the rounded endpoint artifacts that remain
visible into the next refresh.
Touch target sizes
--ink-control-h-sm: 36px;
--ink-control-h-md: 44px; /* default */
--ink-control-h-lg: 48px;The 44px default follows the iOS Human Interface Guidelines minimum, which at
300 ppi corresponds to about 3.7 mm of physical glass. Capacitive layers
laminated onto e-paper modules generally resolve touch less precisely than those
on OLED panels, so the larger default is a reasonable baseline rather than a
generous one. It is a default, not a floor: compact controls opt into
--ink-control-h-sm deliberately — <e-chip> is 36px — and dense grids such
as the calendar's day cells size to content rather than to a control token.
Color on Kaleido panels
Kaleido is the color e-paper panel family currently in production. Its native gamut is exposed as flat tokens, without gradients or alpha compositing, since both would be resolved through dithering:
--kaleido-red: #d11a1a;
--kaleido-orange: #e26a1b;
--kaleido-yellow: #e8c81c;
--kaleido-green: #1f8a3b;
--kaleido-blue: #1e4fb8;<e-kaleido> (src/components/kaleido/kaleido.ts) is a
diagnostic component that renders those colors alongside a Bayer-8 dithered
preview, so a swatch can be evaluated against its likely hardware rendering
before it ships.
Typography
| Role | Stack | Line-height | Rationale |
| ----------- | ------------- | -------------- | ---------------------------------------------------- |
| Long prose | --ink-serif | 1.6 | Serifs remain legible on reflective grayscale. |
| Body / UI | --ink-sans | 1.55 | Above the web norm of ~1.3, since reading is slower. |
| Data / code | --ink-mono | 1.55 | Tabular alignment in forms and tables. |
| Headings | --ink-sans | 1.15 (tight) | Density in toolbars and headers. |
Where this library is not a good fit
The constraints above exclude several common UI patterns. If a project needs any of the following, a conventional component library is the better choice:
- Shimmer effects or spinning progress indicators, which depend on motion.
<e-skeleton>and<e-progress>exist as static placeholders — an outline block and a bar or step indicator that redraw once, with no animated variant. - Hover-driven menus and tooltips, which have no input equivalent on e-paper.
<e-popover>and<e-dropdown>cover the tap-driven equivalents; a true hover tooltip has nothing to bind to. - Soft drop shadows and decorative color gradients or alpha compositing, which
dither unpredictably; this is why Kaleido swatches are flat tokens with no
gradient or alpha (see below). It does not rule out the library's own
1-bit hatch fills, which are
repeating-linear-gradients of two flat colors rather than a blended one, or theopacity: 0used to keep a native<input>interactive while visually hidden under a styled control. - Color-coded status indicators, since Carta is grayscale and Kaleido provides roughly five reliably distinguishable colors.
- Interaction at 60 fps, which the panel hardware cannot deliver.
Install
npm install @marcomattes/epaper-componentsUnreleased work is published continuously from main under the dev dist-tag.
It is useful for verifying a fix before it appears in a release, and is not
intended for production:
npm install @marcomattes/epaper-components@devQuick start
The library ships three CSS layers — tokens, base reset and component styles.
They are separate so that consumers can replace the token layer or skip the
reset. For static HTML the combined bundle is the shortest path; the path below
assumes node_modules is reachable from the document, so in a served
application either copy the file into the public directory or point the tag at
your own asset path.
A bare specifier such as @marcomattes/epaper-components/button is a Node.js
resolution convention read from the package's exports map; a plain
<script type="module"> cannot resolve it on its own. Either import the
built files by their real path under dist/, as below, or add an
import map
that reproduces the exports map's import targets one subpath at a time —
a single prefix mapping does not work here because the barrel resolves to
dist/index.js while every other subpath resolves under dist/components/.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- Combined and minified: tokens + base + components, ~7 KB gzip. -->
<link
rel="stylesheet"
href="node_modules/@marcomattes/epaper-components/dist/styles/epaper.min.css"
/>
<script type="module">
// Register only the components you use, by their built path. A leading
// "./" is required — without it these would be bare specifiers, which
// only a bundler or an import map can resolve.
import './node_modules/@marcomattes/epaper-components/dist/components/button.js';
import './node_modules/@marcomattes/epaper-components/dist/components/input.js';
import './node_modules/@marcomattes/epaper-components/dist/components/form.js';
</script>
</head>
<body class="ink-page">
<e-form>
<e-form-item label="Name">
<e-input name="name" required></e-input>
</e-form-item>
<!-- e-button defaults to type="button"; a form submit control needs type="submit" explicitly. -->
<e-button type="submit" variant="primary">Save</e-button>
</e-form>
</body>
</html>In a project with a bundler, import the layers individually so that tokens remain overridable through plain CSS:
import '@marcomattes/epaper-components/styles/tokens.css';
import '@marcomattes/epaper-components/styles/base.css';
import '@marcomattes/epaper-components/styles/components.css';
import '@marcomattes/epaper-components/button';
import '@marcomattes/epaper-components/input';
import '@marcomattes/epaper-components/select';Importing the barrel registers every component at once:
import '@marcomattes/epaper-components';Stylesheets
Two distributions of the same three layers ship in the package:
| Subpath | Resolves to | When to use |
| -------------------------------------------------------------- | ------------------------------------------ | ---------------------------------------- |
| @marcomattes/epaper-components/tokens.css | src/styles/tokens.css (source) | Bundlers (Vite, Rollup, webpack 5). |
| @marcomattes/epaper-components/base.css | src/styles/base.css (source) | Bundlers. |
| @marcomattes/epaper-components/components.css | src/styles/components.css | Bundlers. |
| @marcomattes/epaper-components/themes/mono-high-contrast.css | src/styles/themes/mono-high-contrast.css | Optional high-contrast monochrome theme. |
| @marcomattes/epaper-components/themes/kaleido.css | src/styles/themes/kaleido.css | Optional Kaleido panel theme. |
| @marcomattes/epaper-components/styles.min.css | dist/styles/epaper.min.css | Combined, minified bundle. |
| @marcomattes/epaper-components/tokens.min.css | dist/styles/tokens.min.css | Standalone minified token layer. |
| @marcomattes/epaper-components/base.min.css | dist/styles/base.min.css | Standalone minified reset. |
| @marcomattes/epaper-components/components.min.css | dist/styles/components.min.css | Standalone minified component CSS. |
The minified files are produced by Lightning CSS
(see scripts/build-css.mjs) during npm run build, and
each one ships with a sibling *.min.css.map. Importing the unminified sources
keeps them readable and allows them to pass through a consumer's own CSS
pipeline.
The pre-upgrade state
base.css hides custom elements that have no registration yet:
.ink-page :not(:defined) {
visibility: hidden;
}Until its module has run, a custom element is an unstyled inline box holding whatever the author wrote between the tags — raw label text, options as run-on prose, no borders. Without this rule the page paints that first and then snaps to the real layout once the script registers the element. On an EPDC that second paint is a full refresh, which is exactly what the surgical-update rules elsewhere in this library exist to avoid.
:not(:defined) matches only unregistered custom elements; built-in elements
always count as defined, so nothing authored in plain HTML is affected. It does
not distinguish this library's elements from anyone else's, though — a
third-party custom element nested inside .ink-page is held back on the same
terms, and stays hidden if its own script never registers it. CSS cannot match a
tag prefix, so the alternative would be a generated list of all 108 tags.
The other side of the trade is that a page whose script never arrives shows nothing at all. Where either case matters, hand the unregistered elements their content back:
<noscript>
<style>
.ink-page :not(:defined) {
visibility: visible;
}
</style>
</noscript>Sizes as of the current 2.0.0 build:
| File | raw | gzip |
| ---------------------------- | ------: | ------: |
| tokens.min.css | 1.8 KB | 0.7 KB |
| base.min.css | 1.5 KB | 0.6 KB |
| components.min.css | 79.0 KB | 11.4 KB |
| epaper.min.css | 82.2 KB | 12.2 KB |
| mono-high-contrast.min.css | 0.8 KB | 0.3 KB |
| kaleido.min.css | 0.4 KB | 0.3 KB |
Subpath imports and bundle size
Every one of the 83 component modules is shipped as a separate ES module under
@marcomattes/epaper-components/<tag>, and the barrel entry registers all 108
of the elements they define. The sideEffects allowlist in package.json
covers the component modules and the public CSS files, so importing a single
subpath pulls in that component plus its shared core chunks and nothing else,
in Vite, Rollup, esbuild and webpack 5.
Compound elements that a parent component registers alongside itself — such as
<e-form-item> (registered by form.ts) or <e-option> (registered by
select.ts) — do not get their own subpath; importing the parent module
registers them too. package.json exposes 113 subpaths in total: the barrel,
84 component entries covering all 108 tags between them, five core-helper
entries, 22 CSS/source-map entries and the Custom Elements Manifest.
| Goal | Import |
| ------------------ | ---------------------------------------------------------------- |
| Just one component | import '@marcomattes/epaper-components/button'; |
| Type imports only | import type { EButton } from '@marcomattes/epaper-components'; |
| Whole library | import '@marcomattes/epaper-components'; |
Bundling the full library through esbuild currently produces 63.7 KB brotli;
npm run size enforces a 66 KB brotli budget on the barrel and separate budgets
on <e-button> (6 KB, currently 1.7 KB) and <e-input> (8 KB, currently
4.92 KB). The per-component budgets are the ones that matter day to day: they
are what catches a shared core module quietly acquiring a dependency that then
ships with every component importing it. The CSS files are declared as having side effects, since they apply
globally, and are never tree-shaken.
Forms
Every interactive control is a form-associated custom
element. Giving a control
a name attribute is enough for it to participate in submission, FormData
and form.reset(). Built-in constraint validation is shared by all 18
BaseFormControl subclasses: required reports valueMissing for empty text,
selection and file controls (and for unchecked checkbox/toggle controls), while
the text and number controls also mirror their native input constraints through
ElementInternals.setValidity(). form.reset() restores every control's
default-value attribute (or default-checked for
<e-checkbox>/<e-toggle>/<e-radio>), not its initial value.
<form id="profile">
<e-input name="email" type="email" required></e-input>
<e-checkbox name="newsletter" label="Subscribe"></e-checkbox>
<e-toggle name="dark-mode"></e-toggle>
<e-select name="region">
<e-option value="eu" label="Europe"></e-option>
<e-option value="us" label="USA"></e-option>
</e-select>
<e-radio-group name="size">
<e-radio value="s" label="S"></e-radio>
<e-radio value="m" label="M"></e-radio>
</e-radio-group>
<e-checkbox-group name="topics" value="a,b">
<e-cbox-option value="a" label="A"></e-cbox-option>
<e-cbox-option value="b" label="B"></e-cbox-option>
</e-checkbox-group>
<e-date-picker name="birthday"></e-date-picker>
<e-time-picker name="alarm"></e-time-picker>
<button type="submit">Submit</button>
</form>
<script type="module">
import '@marcomattes/epaper-components';
document.getElementById('profile').addEventListener('submit', (e) => {
e.preventDefault();
const fd = new FormData(e.target);
// Object.fromEntries(fd) keeps only the last value for a repeated key,
// which silently drops all but one of a checkbox-group's selections.
// Read multi-value fields with getAll() instead:
const data = Object.fromEntries(fd);
data.topics = fd.getAll('topics');
console.log(data);
});
</script>Form controls also expose the standard value, validity, validationMessage,
willValidate, checkValidity() and reportValidity() members through
ElementInternals.
Events
Components communicate through CustomEvents with an e- prefix, all of them
bubbling, and a typed detail payload. The default is an e-change event
carrying { value: T }; the following contracts differ from that default and
are stable API. There are 16 distinct event names in total:
| Event | Detail | Fired by |
| ---------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| e-change | { value: string } | e-input, e-textarea, e-select, e-date-picker, e-time-picker, e-tree-select, e-tabs, e-segmented, e-menu, e-anchor, … |
| e-change | { value: string[] } | e-cascader, e-checkbox-group, e-collapse |
| e-change | { value: number } | e-pagination, e-input-number, e-slider, e-rating |
| e-change | { value: boolean } | e-chip, e-redline |
| e-change | { value: number, band: 'low' \| 'normal' \| 'high' } | e-meter, when the clamped value crosses into another threshold band |
| e-change | { checked: boolean } | e-checkbox, e-toggle |
| e-change | { files: File[] } | e-upload |
| e-input | { value: string } | e-input, e-textarea, e-pin-input, e-slider, before the committed e-change |
| e-click | { originalEvent: MouseEvent } | e-button |
| e-click | { value: number } | e-back-top; value is the scroll position when the button was pressed |
| e-select | { index: number } | e-dropdown |
| e-select | { index: number, value: string } | e-float-button, when a group action is activated |
| e-select | { value: string } | e-tree, e-select, on row or option activation |
| e-select | { value: number[] } | e-table, when the row selection changes |
| e-open | { value: true } | e-dialog, e-popover |
| e-close | { value: string } | e-tag, e-alert, when the dismiss control is activated |
| e-close | { value: false } | e-popover |
| e-close | { value: false, reason: DialogCloseReason } | e-dialog; reason says what dismissed it |
| e-confirm | { value: true } | e-popconfirm |
| e-cancel | { value: false } | e-popconfirm, on cancel, Escape or an outside click |
| e-expand | { value: string, expanded: boolean } | e-tree, when a node expands or collapses |
| e-check | { value: string[] } | e-tree; every fully checked node, parents included, in document order |
| e-load | { value: 'src' \| 'fallback' \| 'placeholder' } | e-image, when a source finishes rendering |
| e-sort | { key: string, direction: 'asc' \| 'desc' \| 'none' } | e-table, on a header sort click (the component never reorders rows itself) |
| e-submit | { form: HTMLFormElement } | e-form (the native submit is preventDefault-ed) |
| e-invalid | { controls: HTMLElement[], form: HTMLFormElement } | e-form, once per blocked submission, listing the controls that failed constraint validation |
| e-error | { error: Error, source: string } | e-cascader, e-tree, e-tree-select, e-calendar, e-agenda, e-form on malformed JSON attributes |
| e-error | { value: string } | e-image, e-card-image, when a source fails to load; value is the URL |
| e-month-change | { year: number, month: number } | e-calendar, when the displayed month moves. month is zero-based, like Date#getMonth |
The complete per-component list, including slots and attributes, is generated
into dist/custom-elements.json. For typed listeners the package exports an
EChangeDetail<T> helper. Because 'e-change' isn't a key of
HTMLElementEventMap, addEventListener falls back to its untyped overload,
whose listener parameter type is Event — a listener typed to take
CustomEvent<EChangeDetail<T>> directly is narrower, and TypeScript's strict
mode rejects the assignment (Argument of type '(e: CustomEvent<...>) => void'
is not assignable to parameter of type 'EventListenerOrEventListenerObject').
Type the parameter as Event and narrow inside the body instead:
import type { EChangeDetail } from '@marcomattes/epaper-components';
el.addEventListener('e-change', (e: Event) => {
const { value } = (e as CustomEvent<EChangeDetail<string>>).detail;
console.log(value);
});Theming
CSS custom properties are the public theming surface; the registry is documented in THEMING.md.
E-paper data display
Seven display components cover persistent dashboard states without animation or color-only meaning:
| Component | Purpose |
| ----------------- | -------------------------------------------------------------- |
| e-meter | Bounded measurement rendered as discrete segments. |
| e-sparkline | Monochrome mini-chart with a readable trend cue. |
| e-status-board | Keyed KPI matrix that patches changed cells in place. |
| e-change-marker | Compact current value with a cue only when it changed. |
| e-last-updated | Relative update age with fresh, stale and expired states. |
| e-diff | Persistent previous/current comparison without color reliance. |
| e-event-log | Keyed event/alarm list that inserts new rows in place. |
e-last-updated deliberately starts no timer. Update its now attribute from
the application's existing refresh cycle, or call refresh() when a panel
redraw is already planned.
e-event-log is keyed by the id of each row. A new event is inserted as a
single node and an existing row is patched in place, so a live alarm list costs
a partial refresh of one row rather than a full-page flash — which is why it
replaces the e-timeline / e-list / e-table improvisations that live logs
used before.
Schedules, retail labels and kiosk input
| Component | Purpose |
| ------------- | ------------------------------------------------------------------------------------ |
| e-agenda | Day or week agenda on a proportional time axis, with the free stretches labelled. |
| e-price | Retail price with split major/minor typography, strike-through original, base price. |
| e-barcode | EAN-13, EAN-8, UPC-A and Code 128 as 1-bit SVG, from a self-contained encoder. |
| e-rating | Star or smiley rating with 48px targets and full keyboard control. |
| e-slider | Range slider with a wide grip, a printed readout and optional tick marks. |
| e-pin-input | Fixed-length code entry as digit boxes, with auto-advance and paste support. |
| e-signature | Signature pad on a canvas, submitted as a PNG File. |
| e-keypad | On-screen numeric keypad, optionally typing into the control named by for. |
e-agenda reads the same {date, title, start?, end?, status?} entries as
e-calendar — an entry without start is an all-day entry — and its "now"
marker is drawn only from the now attribute, so, like e-last-updated, it
owns no timer. e-calendar fires e-month-change when the displayed month
moves, which is the hook for loading that month's entries and writing them back
to events.
The five kiosk controls are all form-associated: they participate in <form>
submission, FormData, reset and BFCache restore like every other control in
the library.
TypeScript and IDE integration
- TypeScript: importing the package augments
HTMLElementTagNameMap, sodocument.querySelector('e-button')is typed asEButton. - VS Code:
dist/vscode.html-custom-data.jsonprovides tag and attribute completion in plain HTML. The package'scontributes.html.customDataentry is the manifest field VS Code extensions use — npm does not install it as one, so VS Code never reads it from a dependency automatically. Point the workspace at the file explicitly in.vscode/settings.json:{ "html.customData": [ "./node_modules/@marcomattes/epaper-components/dist/vscode.html-custom-data.json" ] } - WebStorm:
dist/web-types.jsonprovides the same in JetBrains IDEs via theweb-typesfield. - Custom Elements Manifest:
dist/custom-elements.jsonfollows the CEM 1.0 schema.
Browser support
The library targets current evergreen browsers (Chrome, Edge, Safari and Firefox, last two versions). Form-associated custom elements require Safari 16.4+, Chrome 77+ or Firefox 98+. No polyfill is bundled.
Framework integration
The components are plain custom elements, so any framework that renders HTML can use them. The examples below are per-framework conventions; nothing in the library is framework-aware.
The one detail worth knowing in advance concerns events. Custom events with hyphenated names are not covered by the event systems of React or Vue's JSX transform, so in those environments listeners are attached imperatively. Vue templates, Angular templates and Svelte have syntax for it.
Server-side rendering
The library imports cleanly on a server. Every entry point — the barrel and each
subpath — can be imported from a Next.js, Nuxt, Astro or SvelteKit render
without a DOM: the element classes are constructed, registration is skipped
because there is no customElements registry, and the components upgrade when
the module is evaluated again in the browser. Nothing in the library reads
document or window while a module is loading. So the plain top-level import
in the examples below is safe, and needs no typeof window guard or dynamic
import.
What server rendering does not do here is produce markup. The components are
light-DOM only and build their subtree in connectedCallback, which runs in a
browser and nowhere else, so the server emits the custom element tag with the
authored children inside it and nothing more. Two consequences worth designing
around:
- The first paint is the pre-upgrade one.
base.csshides not-yet-registered elements (.ink-page :not(:defined)) so a page repaints once instead of flashing unstyled content and then snapping into place — on an e-paper panel that second repaint is a full refresh, not a free one. The trade is that a page whose script never arrives shows nothing; the rule is one line to undo, see Stylesheets. - Do not pre-render the components' own output. A snapshot of the rendered
subtree, fed back in as server HTML, is not adopted on upgrade — the component
renders again around it and you get the markup twice over. Ship the authored
form (
<e-button>Save</e-button>), not the upgraded one.
Hydration in the framework sense — server markup that the client adopts in place — would need Declarative Shadow DOM, which this library deliberately does not use. It is not on the roadmap.
React
React 19 supports custom elements in the sense that it sets primitive props as
attributes and non-primitive props as properties. It does not register listeners
for custom events, and there is no on-<event> prop convention, so a prop like
on-e-change would be assigned as a property of that name and never fire.
onSubmit is a React synthetic event bound to the native submit event and
will not observe e-submit either. Attach e-* listeners through a ref, in
React 19 as well as in earlier versions:
import { useEffect, useRef } from 'react';
import '@marcomattes/epaper-components/input';
export function EmailField() {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const onChange = (e: Event) => console.log((e as CustomEvent<{ value: string }>).detail.value);
el.addEventListener('e-change', onChange);
return () => el.removeEventListener('e-change', onChange);
}, []);
return <e-input ref={ref} name="email" type="email" required />;
}Attributes, on the other hand, work as written: name, type and required
above are passed through to the element.
Vue 3
Vue needs to be told which tags to leave alone, after which the @ shorthand
binds custom events directly:
// vite.config.ts
import vue from '@vitejs/plugin-vue';
export default {
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith('e-'),
},
},
}),
],
};<script setup lang="ts">
import '@marcomattes/epaper-components/select';
const onChange = (e: CustomEvent<{ value: string }>) => console.log(e.detail.value);
</script>
<template>
<e-select name="region" @e-change="onChange">
<e-option value="eu" label="Europe" />
<e-option value="us" label="USA" />
</e-select>
</template>Angular 17+
Angular requires CUSTOM_ELEMENTS_SCHEMA once per component and accepts
hyphenated event names in its standard binding syntax:
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@marcomattes/epaper-components/checkbox';
@Component({
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: ` <e-checkbox name="agree" (e-change)="onChange($event)"></e-checkbox> `,
})
export class AgreeFormComponent {
onChange(e: CustomEvent<{ checked: boolean }>) {
console.log(e.detail.checked);
}
}Svelte 5
Svelte treats any hyphenated tag as a custom element, so no configuration is
needed. Event binding uses the on: directive; it is deprecated in Svelte 5 in
favour of onclick-style properties, but remains the only syntax that can
express a hyphenated event name such as e-change:
<script lang="ts">
import '@marcomattes/epaper-components/toggle';
function onChange(e: CustomEvent<{ checked: boolean }>) {
console.log(e.detail.checked);
}
</script>
<e-toggle name="dark" on:e-change={onChange} />TypeScript JSX augmentation
The shipped dist/elements.d.ts augments HTMLElementTagNameMap, which covers
document.createElement and document.querySelector. JSX typing is
framework-specific and belongs in the consuming project:
// src/types/epaper-jsx.d.ts
import type { EButton, EInput } from '@marcomattes/epaper-components';
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'e-button': React.DetailedHTMLProps<React.HTMLAttributes<EButton>, EButton>;
'e-input': React.DetailedHTMLProps<React.HTMLAttributes<EInput>, EInput>;
}
}
}Status
The 1.0 line is stable. Component attributes, events, slots and exported classes
are public API and follow Semantic Versioning; breaking
changes require a major version and an entry in
CHANGELOG.md, which also documents the known limitations of
the current release.
Repository
An npm-workspaces monorepo: one published package, four unpublished apps.
packages/
epaper-components/ # The published package.
src/
core/ # Cross-cutting helpers: dom, icons, base-form-control,
# types, date, format, i18n.
components/ # One directory per component; each calls define(...)
# at module scope.
styles/ # Base CSS layers and optional panel themes.
# Public CSS surface.
stories/ # Storybook documentation; not shipped.
demo/ # Demo HTML wiring; not shipped.
apps/
storybook/ # Storybook configuration; the stories live in the package.
site/ # Source of epaper-components.dev.
sample-app/ # Runtime + compiled checks that this README stays accurate.
bookstore/ # Larger end-to-end example app.Companion docs
| File | Audience | Purpose |
| -------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------- |
| OVERVIEW.md | Users + contributors | Architecture deep-dive, API conventions, event-detail contract, framework-integration cheatsheet, known limitations. |
| THEMING.md | Users | CSS custom-property registry and override patterns. |
| CONTRIBUTING.md | Contributors | Component-author conventions, release process and PR checklist. |
| CHANGELOG.md | Everyone | Version history, migrations, and known limitations. |
| AGENTS.md | AI agents | Working guide for Claude Code and similar tools. |
Development
npm install
npm run dev # Vite playground (packages/epaper-components/src/demo) on http://localhost:8085
npm run storybook # Storybook on :6006
npm run test # Vitest in watch mode (browser/Chromium via Playwright)
npm run test:ci # Vitest single run, CI-friendly
npm run test:refresh # E-paper mutation and dirty-area budgets
npm run test:coverage # Watch mode + v8 coverage report
npm run test:coverage:ci # Single run + v8 coverage report
npm run test-storybook # Run Storybook a11y/interaction tests
npm run build # ES bundles + d.ts + minified CSS + CEM + tag map
npm run build-storybook # Static Storybook for hosting
npm run analyze # Re-emit custom-elements.json + elements.d.ts
npm run lint # ESLint
npm run lint:check # ESLint with --max-warnings=0 (CI gate)
npm run type-check # tsc --noEmit
npm run format # Prettier write
npm run format:check # Prettier check (CI gate)
npm run size # size-limit budget check on dist/
npm run size:why # size-limit with bundle-analyzer
npm run validate:sample-app # this README's runtime claims, against dist/, in Chromium
npm run validate:sample-app:types # this README's TypeScript snippets, strict-compiled against dist/Build pipeline
npm run build runs four stages in order:
vite buildemits per-component ES modules todist/components/*.js, the barreldist/index.jsand shared chunks underdist/chunks/, each with a source map.node scripts/build-css.mjscopies the base layers and optional themes fromsrc/styles/todist/styles/, runs them through Lightning CSS, writes*.min.cssplus*.min.css.map, and concatenates the three base layers intodist/styles/epaper.min.css.tsc -p tsconfig.build.jsonemits the.d.tsfiles intodist/.cem analyzeandnode scripts/gen-tag-map.mjsproducedist/custom-elements.json,dist/web-types.json,dist/vscode.html-custom-data.json,dist/vscode.css-custom-data.jsonanddist/elements.d.ts, which carries theHTMLElementTagNameMapaugmentation.
Static analysis and coverage
SonarQube Cloud
analyses every pull request and every push to main. The scan is the sonar
job in .github/workflows/ci.yml; it does not run the suite itself but consumes
the reports the test job produced, so the numbers on the dashboard are the
numbers CI actually measured:
| Report | Produced by | Read via |
| ---------------------------- | ----------------------- | ----------------------------------- |
| reports/coverage/lcov.info | @vitest/coverage-v8 | sonar.javascript.lcov.reportPaths |
| reports/test/sonar.xml | vitest-sonar-reporter | sonar.testExecutionReportPaths |
There is only one test runner. Playwright is not a second suite — it is the
browser provider Vitest drives in browser mode, and two of the three Vitest
projects, unit (src/**/*.test.ts) and storybook (the a11y and interaction
stories), run in that same Chromium instance within a single vitest run. The
third, ssr (src/**/__ssr__/), runs in a Node environment instead: it covers
the paths that only exist where there is no DOM, which a browser cannot reach.
V8 collects coverage per project and merges it before writing, so lcov.info is
already the union of all three; nothing has to be combined afterwards. Should a genuinely separate
runner ever be added, sonar.javascript.lcov.reportPaths takes a
comma-separated list and Sonar unions the files itself.
sonar-project.properties holds the full configuration. The one rule to
remember when editing it: sonar.coverage.exclusions must mirror
coverage.include in vitest.config.ts. Sonar reads a source file with no
entry in lcov.info as 0% covered rather than as unmeasured, so a path that the
test run deliberately ignores has to be excluded on the Sonar side too.
The quality gate is blocking — sonar.qualitygate.wait=true makes a failing
gate fail the job instead of turning the badge red after CI has already passed.
Analysis needs a SONAR_TOKEN repository secret; without one the job logs a
notice and skips, which is what happens on pull requests from forks.
Contributing
Pull requests are welcome; see CONTRIBUTING.md for the
component-author conventions. Before opening one, confirm that
npm run lint:check, npm run type-check, npm run test:ci and npm run build
pass locally. CI runs the same checks plus Prettier and the size-limit budgets on
every pull request and on every push to main. CodeQL analysis runs alongside
it through GitHub's code scanning default setup, which is configured in the
repository settings rather than as a workflow file.
Releasing
Releases are handled by .github/workflows/release.yml, which serves two npm
channels.
Every push to main that passes CI publishes a <next-patch>-dev.<build>
version under the dev dist-tag. The version is written inside the job and is
never committed, so package.json on main always reflects the last release.
A pushed tag matching v<package.json version> runs the release path in four
stages, each gating the next:
- Guard confirms that the tag name matches the version in
package.jsonand resolves the target dist-tag. A tag on a commit that was never bumped fails here, within seconds. - Checks runs the quality gate by calling
ci.ymlas a reusable workflow, so the release gate and the pull-request gate are the same definition rather than two copies. - Release publishes to npm with
provenance through
OIDC trusted publishing, and creates a GitHub Release whose notes are the
matching
CHANGELOG.mdsection, with the packed tarball attached. - Update installs the freshly published version from the registry into an empty project, registers the elements in jsdom to confirm the artifact works as shipped, verifies the provenance attestation, and redeploys the site and Storybook from the tagged source.
Tags carrying a prerelease part, such as v1.1.0-rc.1, are published under the
next dist-tag instead of latest. The site is additionally redeployed after
the complete staged CI has passed on every push to main.
main requires a pull request, so the version bump is merged before the tag is
pushed; CONTRIBUTING.md documents the exact sequence.
Cross-browser testing
Every component story is tested on Chrome, Edge and desktop WebKit through
BrowserStack. Firefox and mobile WebKit are configured in browserstack.yml
but held out of the CI matrix; .github/workflows/browserstack.yml records why
next to the platform: list. The suite rejects runtime and
rendering failures and also enforces that every registered custom element is
covered by Storybook. See BROWSERSTACK.md for the complete
matrix, CI setup, reports, and local commands.
License
MIT. See LICENSE.
