English | 日本語
🔗 Live demo: https://y1-effy.github.io/HelpLayer/ (Vanilla / React / Vue — turn on "Help mode" top-right, then click an "i")
A framework-agnostic "help mode" library you can drop into any existing web app. While the mode is ON, it shows a "?" marker next to each target element; clicking it opens a description popup. The normal appearance is completely unchanged. It never touches the host app's own event listeners — a transparent blocking layer absorbs interaction instead — so you can adopt it without rewriting existing code.
- Zero runtime dependencies; lightweight (the prebuilt IIFE is ~20KB minified)
- Pierces Shadow DOM, keeps up with dynamically added/removed elements in SPAs, avoids marker-to-marker overlap, and auto-adjusts the popup at screen edges
- Mindful of keyboard use and screen readers (the popup is
role="dialog"; opening moves focus and closing returns it to the marker; while the mode is on, focus is trapped within the UI, andEsccloses the popup — or exits the mode when no popup is open) - Fully cleans up the DOM, listeners, and styles it added when you turn it OFF
- Works in modern browsers (Chromium / Firefox / WebKit; e2e is verified across all three engines)
- Why HelpLayer (vs. existing options)
- Installation
- Quick start
- When it fits (where adoption pays off)
- Free placement (descriptions not bound to an element)
- API
- Recipes on top of the API (analytics, deep-linking, search, frameworks)
- Theming (CSS custom properties)
- Browser & runtime support
- Known limitations
- Performance (how many markers)
- Accessibility
- Security
- Development
There are many ways to add explanations to a screen, but each comes with its own assumptions. HelpLayer commits fully to "a help mode where users freely pick just the spots they want to understand and check them on the spot," aiming to sacrifice neither the normal look nor your existing code.
- vs. product tours (step-by-step guidance) … Rather than marching users along a fixed route, it's exploratory: users pick the element they want and open it right there. The timing and order of reading are left entirely to the user.
- vs. always-on tooltips … It never clutters the UI by showing explanations all the time. Markers appear only while the mode is ON, so the normal design is completely unchanged.
- vs. DAP SaaS (Digital Adoption Platform) … No external platform, contract, or tracking required — zero running cost, zero dependencies, ~20KB, fully local. It also supports CSP / Trusted Types, so it fits environments with strict constraints on what you can bring in.
On top of that, they all share a common core: you can drop it in without rewriting existing code, it's framework-agnostic, it never touches the host app's own events (a transparent blocking layer absorbs interaction), and it fully cleans up on ON→OFF.
| Product tours | Always-on tooltips | DAP SaaS | HelpLayer | |
|---|---|---|---|---|
| Presentation | tends to be linear steps | tends to be always visible | service-dependent | only while ON · explore any spot |
| Normal UI | depends on implementation | tends to get cluttered | depends on implementation | left entirely unchanged |
| Adoption | usually needs integration | add CSS/JS | snippet + external platform + contract | drop-in · no existing-code changes |
| Cost / ops | depends on implementation | local | monthly fee + tracking ops | zero running cost · zero dependencies |
Note: HelpLayer is not a full replacement for a DAP. Advanced features like analytics, segmented delivery, complex flow guidance, and onboarding automation are out of scope — it commits to satisfying just the core "show explanations in-screen" function at minimal cost. Conversely, if your main goal is to drive strong funnels or measure usage, a DAP or a tour is the better fit.
That said, several of these (analytics, deep-linking, a search palette, framework glue) are easy to build on top of the public API in a few lines — see RECIPES.md for recipes.
npm install help-layerIf you'd rather drop it in with a single <script> and no bundler, load the prebuilt IIFE, which exposes a global HelpLayer (see below).
TypeScript type definitions are bundled (package.json's types points to dist/types), so type completion works with no extra setup in TS projects. The public types are importable, e.g. import type { HelpLayerOptions, HelpLayerController, HelpConfig, HelpRecord, Placement } from 'help-layer'.
Add data-help-id to a target element and pass a description keyed by that value.
<button data-help-id="save">Save</button>
<button id="help-toggle">Help mode</button>import { initHelpLayer } from 'help-layer';
initHelpLayer({
toggle: '#help-toggle',
config: {
save: { title: 'Save', text: 'Saves your input.' },
},
});If you'd rather keep descriptions next to your markup, just add data-help-title / data-help-text to an element and it becomes a target.
This can be combined with config, and if the same key exists in config, the config wins.
<button data-help-title="Save" data-help-text="Saves your input.">Save</button>initHelpLayer({ toggle: '#help-toggle' });When loading from a CDN, we recommend pinning the version and adding SRI (integrity) so tampering is detectable.
<script
src="https://unpkg.com/help-layer@1.5.0/dist/help-layer.iife.js"
integrity="sha384-……(replace with the published file's hash)"
crossorigin="anonymous"></script>
<script>
HelpLayer.initHelpLayer({
toggle: '#help-toggle',
config: { save: { title: 'Save', text: 'Saves your input.' } },
});
</script>Generate the
integrityhash from the actually published file, e.g.:curl -s https://unpkg.com/help-layer@1.5.0/dist/help-layer.iife.js | openssl dgst -sha384 -binary | openssl base64 -A(If you don't pin the version, the SRI will mismatch and the browser will refuse to load it.)
- A DAP / guide SaaS isn't worth the cost and you're considering canceling — but canceling drops your in-screen help back to zero. → Keep just the core "show explanations in-screen" function in-house, with zero dependencies and zero running cost. It gives you a place to land after switching away.
- You don't have the budget to contract a SaaS, but you want to expand your help.
→ Drop it in with npm or a single
<script>. No monthly fee, no account. - Maintaining a separate manual in an office suite is a chore — and nobody reads it even when you do.
→ Co-locate the explanation with the very element on screen (
data-help-title/data-help-textor a small config). You're freed from maintaining a separate doc, and the UI and its explanation never drift apart. - You want onboarding, but a forced tour feels pushy and you'd rather avoid it. → It's exploratory — users pick what they want and open it on the spot — so it never interrupts their work.
- Environments where you can't bring in an external SaaS (strict CSP, privacy requirements, closed networks, no tracking allowed). → It meets those requirements with fully local operation and no external communication.
- Regardless of framework (React / Vue, etc.), you want to adopt it without touching your rendering library. → Framework-agnostic and drop-in; it doesn't rewrite your existing code.
Business systems and admin screens are the easiest fit, but the use isn't limited to them. On ordinary websites too, you can supplement "what to enter in this field" on signup, contact, or reservation forms with a marker + popup. Any "existing web page you want to add explanations to" is in scope, and it makes a lightweight alternative to maintaining a separate manual.
💡 It works for desktop apps, too. Electron / Tauri and the like render their app screens with a WebView (HTML/DOM), so you can drop HelpLayer in exactly as you would in a web app. It's a surprisingly natural option when you want to add a "help mode" to a native-feeling screen.
Specify position to place a marker at a page coordinate instead of on a specific element (useful for whole-screen descriptions, etc.).
config: {
intro: { title: 'About this screen', text: '…', position: { top: 80, left: 560 } },
}const help = initHelpLayer(options);
help.enable(); // ON
help.disable(); // OFF
help.toggle(); // flip ON/OFF
help.isActive(); // boolean
help.open(key); // open the description for the given key (auto-enables if OFF).
// If several elements share that data-help-id, it opens the first (mount order) and warns.
help.close(); // close the open description (the mode stays ON)
help.update(newConfig); // replace the config (rebuilds silently if ON; onEnable/onDisable are not called)
help.diagnose(); // scan the live DOM and log/return how the config maps onto it (works ON or OFF)
help.destroy(); // detach listeners + full cleanup| Option | Type | Default | Description |
|---|---|---|---|
config |
object |
{} |
key → { title, text, position? }. The key is a data-help-id value or a free-placement key. Optional — omit it to define targets purely via inline data-help-title / data-help-text |
toggle |
string | HTMLElement |
none | the toggle element that switches ON/OFF. If omitted, control is programmatic-only |
attribute |
string |
'data-help-id' |
attribute name marking targets |
render |
(record) => Node | null |
none | render the body with your own DOM. Falls back to safe text display when nothing is returned (the title is always record.title) |
markerLabel |
string |
'?' |
the character shown on the marker |
markerPlacement |
Placement |
'top-end' |
where to overlap the marker onto the target — any Placement value (the same 12 as popupPlacement); the corners (e.g. top-end/bottom-start) are the usual choice |
popupPlacement |
Placement |
'bottom-start' |
initial popup placement (flips/shifts automatically at screen edges) |
markerAriaLabel |
(title: string) => string |
`Help: ${title}` |
build a marker's aria-label from the help title — localize the screen-reader announcement |
closeLabel |
string |
'Close' |
aria-label for the popup's close (×) button — localize the screen-reader announcement |
nonce |
string |
none | nonce to allow the injected <style> under a strict CSP (style-src 'nonce-…'); see below |
silent |
boolean |
false |
suppress non-fatal warning logs (unregistered keys, unknown options, duplicate-id open) |
debug |
boolean |
false |
dev aid: also expose diagnose() as window.helpLayerDiagnose for the devtools console |
| Option | When it fires |
|---|---|
onEnable |
right after the mode is turned ON |
onDisable |
right after the mode is turned OFF |
onOpen(record) |
when a description popup is opened |
onClose |
when a description popup is closed |
Note: if a description is open when you call
update()/disable()/destroy(), the cleanup closes it, soonClosefires once.
For safety the body is rendered with textContent by default (HTML is not interpreted), but \n is shown as a line break.
If you need links or styling, return your own DOM from render.
initHelpLayer({
config,
render(record) {
if (record.key !== 'save') {
return null; // fall back to the default text display
}
const a = document.createElement('a');
a.href = '/docs/save';
a.textContent = 'Learn more';
return a;
},
});
⚠️ Security: the DOM returned byrenderis inserted as-is and is not sanitized by the library. If you use untrusted data (e.g. user input), don't build it withinnerHTML— usetextContent, or neutralize it with something like DOMPurify before returning it (to prevent XSS). The default (norender)title/textrendering usestextContent, so it is safe.
You can change the look just by overriding the following variables in your host CSS. Dark-mode defaults
(prefers-color-scheme: dark) are built in, but any variable you set always wins via var().
| Variable | Default | Purpose |
|---|---|---|
--help-layer-marker-size |
24px |
marker diameter (default meets WCAG 2.5.8 minimum target size) |
--help-layer-marker-bg |
#2563eb |
marker background color |
--help-layer-marker-color |
#fff |
marker text color |
--help-layer-popup-bg |
#fff |
popup background color |
--help-layer-popup-color |
#1f2933 |
popup text color |
--help-layer-popup-max-width |
280px |
popup max width |
--help-layer-popup-max-height |
50vh |
popup body max height (the body scrolls when exceeded) |
--help-layer-accent |
#1d4ed8 |
focus ring color |
--help-layer-overlay-bg |
transparent |
blocking-layer (scrim) background; e.g. rgba(0,0,0,0.15) to signal the host is inactive |
--help-layer-overlay-cursor |
default |
cursor over the blocked area; e.g. not-allowed / help |
HelpLayer targets modern evergreen browsers (Chrome / Edge, Firefox, Safari) and the Chromium in
recent Electron. Internet Explorer 11 is not supported and cannot be — the library relies on ES2020
syntax, ES modules, Shadow DOM, and clip-path, none of which IE provides. Packaging
changes can't bridge this; if you must support genuinely old runtimes, this library is not the right fit.
What sets the minimum (the two newest APIs degrade gracefully, so the practical hard floor is roughly 2020-era evergreen):
| Feature | Used for | Minimum | Fallback |
|---|---|---|---|
| ES2020 + ES modules | the whole library | evergreen (~2020) | none — transpile/bundle for older targets |
requestAnimationFrame |
marker/popup auto-positioning (per-frame tracking) | evergreen | none |
| Open Shadow DOM piercing | finding targets inside shadow roots | evergreen | closed shadow roots are unsupported by design |
clip-path: polygon() |
the blocking layer's toggle "hole" | evergreen (very old Safari needs -webkit-) |
none |
inert |
removing the host from the a11y tree | Chrome 102 / FF 112 / Safari 15.5 (2023) | degrades to visual + keyboard blocking only |
Element.checkVisibility() |
hiding a marker when its target is hidden | Chrome 105 / FF 125 / Safari 17.4 (2024) | falls back to a 0×0-rect check (detects display:none only) |
- ESM (default).
import { initHelpLayer } from 'help-layer'resolves to the prebuilt, testeddist/help-layer.esm.js(self-contained — no runtime dependencies to resolve). - No bundler /
<script>/ CDN / strict environments. Use the self-contained IIFE build, which exposes a globalHelpLayer— see Use it with just a<script>. - CommonJS (
require). Norequireentry is provided: this is a browser-only DOM library, so a Node CJS context can't use it meaningfully. In non-ESM toolchains, consume the ESM build via your bundler, or load the IIFE build above.
The engines field requires Node.js ≥ 18, but this is only for tooling — the CLI
(help-layer check / scaffold), the build, and installing the npm package. The library itself runs in
the browser and needs no Node.js at run time; loading the ESM or IIFE build in a page has no Node dependency.
- Closed Shadow DOM is unreachable from JS, so it is unsupported (only open shadow roots are pierced).
- An open shadow root that is
attachShadow()ed after its host is already connected isn't auto-detected: theMutationObserversees the host'schildList, not a later shadow attach. If a web component upgrades or initializes its shadow root asynchronously, its targets won't get markers until the tree is re-scanned — callupdate(config)(it tears down and rebuilds, re-piercing all current open shadow roots) after the component has initialized, or toggle the mode OFF→ON. - Only one HelpLayer instance per document is supported. Two instances active at once would compete over
document-level resources (the
inerta11y isolation, the injected<style>, thewindow.helpLayerDiagnoseglobal), so initializing a second one while another is still live logs a warning. Calldestroy()on the previous instance before re-initializing. (Separate documents/iframes are independent.) - A custom
--help-layer-marker-sizeis honored — markers read their real rendered size at runtime, so resizing them keeps them anchored to the target. The one caveat: the size is measured once and cached, so changing the variable after markers are already placed isn't re-measured until they remount (toggle OFF→ON orupdate(config)). - Target state changes are not watched (only layout and presence are). While ON, a marker
follows its target's position/size changes and is added/removed as the target enters/leaves the DOM,
and it hides/reshows when the target itself is hidden/shown (e.g.
display:none). However, changes to a target's attributes or content are not detected: adding/removing thedata-help-idattribute on an existing element, rewritingdata-help-title/data-help-text, or toggling state likedisabledwon't update the markers. This is intentional — watching every attribute mutation across the whole document (MutationObserverwithattributes: true, subtree: true) fires on every class/style change and is a performance footgun for a drop-in library. If you change such state, rebuild viaupdate(config), toggle the mode OFF→ON, or re-insert the target element into the DOM (re-insertion is picked up by thechildListobservation).
The cost scales with the number of markers visible at once, not with the size of your config.
All visible markers are positioned together in one shared requestAnimationFrame loop (no
per-marker watchers, no positioning library), so while the page scrolls or animates each frame does a
single batched read → compute → write pass over the visible markers.
Markers only exist while the mode is ON and only for targets currently present and shown — a target
hidden via display:none (or otherwise reported hidden by checkVisibility) has its marker excluded
from positioning, layout measurement, and overlap avoidance. So what matters is "how many are on the
page right now," not how many keys you registered.
Marker-to-marker overlap avoidance is O(n²) per pass, but it's capped at a few iterations with a small
constant and runs only when something actually moved; each pass reads every visible reference rect just
once (reads and writes are phase-separated, so there's no per-marker layout thrashing). What you'd feel
first at scale is the per-frame tracking, which grows linearly with the number of visible markers.
Rough guidance (markers shown at the same time):
- Up to a few hundred — comfortable on typical hardware.
- ~1000 — still smooth in our testing (a locked 60fps under continuous auto-scroll on the bundled stress page).
- Well beyond that — the per-frame tracking during scroll/animation eventually shows.
How to reproduce this. The numbers above come from the bundled stress page
(demo/stress.html; run npm run demo and open
http://localhost:5500/demo/stress.html, or use the hosted
https://y1-effy.github.io/HelpLayer/stress.html). Turn Help mode ON, pick a marker count
(100–1000), and enable Auto-scroll — the worst case, where every visible marker re-tracks on each
frame. The meter shows a moving average of requestAnimationFrame deltas as frame ms and fps;
measure on your own target hardware (browser/CPU/zoom all move the number), and compare builds by
swapping the source under it (e.g. git stash). Reported figures are from our own runs, not a fixed
reference machine.
Even so, the mode is exploratory — users pick the spot they want, so a screen rarely needs more than
a few dozen markers. On large pages, scope your targets, or split them per page/tab (e.g. swap sets with
update(config), or use a different attribute) to keep the number shown at once down. This mirrors the
deliberate choice in Known limitations not to watch every attribute mutation across
the document — both avoid per-frame work that doesn't pay for itself.
While the mode is ON, the host app is blocked not only visually and for pointer/keyboard input, but
also semantically for assistive technology: the host is removed from the accessibility tree with
the inert attribute, so a
screen reader's virtual cursor (browse mode) can't read or activate background content. Only the help
markers, the popup, and your toggle stay reachable. The popup is a role="dialog" with
aria-modal="true", and focus moves into it on open and returns to the marker on close.
Bounded limitations of this isolation:
inertcan't be cancelled on a descendant of an inert subtree, so isolation is applied at the document-body top level (like the clip-path "hole" that lets the toggle show through). The toggle must stay operable, so the top-level branch containing your toggle is left reachable — keep the toggle at/near the body level to minimize what leaks (none if it's a direct<body>child).inertis broadly supported in current browsers; on engines without it, the visual/keyboard blocking still applies, but AT exclusion degrades gracefully (no error).
To report a vulnerability and for the support / security-release policy, see SECURITY.md. Please use GitHub's private vulnerability reporting rather than a public issue.
- By design,
title/textare rendered withtextContentonly;innerHTML/eval/new Functionare never used. - There is no external communication (
fetch, etc.) and no storage use (localStorage/cookie) — it runs fully locally. - The only path through which untrusted data is inserted into the DOM as HTML / DOM nodes is the
renderoption. Its return value is not sanitized, so neutralize it on the caller side if it contains user input (see "Line breaks & links in the body" above). - The library has no runtime dependencies. When using a CDN, pin the version and add SRI as noted above.
- What "doesn't touch the host's events" means. HelpLayer never removes, replaces, or wraps the host
app's own event listeners; while ON, the transparent blocking layer absorbs pointer input and key
presses so normal operation is prevented. This is not a guarantee that the host observes no
events: a global listener the host registered on
window/documentin the capture phase before HelpLayer started can still run before the layer suppresses the event. Treat it as "your existing handlers are left intact and ordinary interaction is blocked," not "no event can ever be observed."
Because this library never uses innerHTML / eval, it works as-is with Trusted Types
(require-trusted-types-for 'script'). Positioning is done by assigning directly to an element's .style (CSSOM),
which is outside the scope of CSP.
The one thing to watch out for is the <style> tag injected for appearance. Under a strict CSP whose
style-src has neither 'unsafe-inline' nor a nonce, this <style> is blocked and the markers/popup get no styles.
On sites that operate with style-src 'nonce-…', pass the per-request nonce via the nonce option.
// pass the nonce your server issues per request (the same value as `style-src 'nonce-xxxx'` in the CSP header)
initHelpLayer({ config, toggle: '#help-toggle', nonce: pageNonce });This lets the injected <style nonce="xxxx"> be allowed by the CSP, so it renders correctly even under a strict CSP.
On sites that allow 'unsafe-inline' or have no CSP, nonce is not needed.
Check that your helpConfig and your markup actually line up — without running the app. It scans
your source files for data-help-id literals and cross-references them with the config, so you can
catch typos and missing definitions straight from the terminal (or in CI).
npx help-layer check --config ./src/helpConfig.js --src ./src| Option | Meaning |
|---|---|
--config <path> |
Config file. A .json, or a .js/.mjs module (default export, a named export via --export, or a no-arg factory). |
--src <path...> |
Source roots to scan (repeatable or comma-separated). Default: current directory. |
--ext <list> |
Comma-separated extensions to scan. Default: html,htm,jsx,tsx,vue,js,ts,mjs,svelte. |
--export <name> |
Which export holds the config (a function export is called with no arguments). |
--attribute <attr> |
Target attribute name (default data-help-id). |
--strict |
Exit non-zero on warnings too, not just errors. |
It reports: bound (config ↔ markup wired up), free (position-based entries), inline
(rendered via data-help-title/data-help-text, no config), unusedConfig (a warning — a config
key never seen in the markup, e.g. a typo or a dynamically-computed id), and missingConfig (an
error — an id present in markup with no config and no inline definition, so no marker would show).
Exit code is 1 when there are errors (or warnings too under --strict), making it CI-friendly.
Static analysis only sees string-literal ids. A computed id (
data-help-id={expr}) can't be resolved, so its config key shows up underunusedConfig(a warning, not an error).
Adopting HelpLayer on an existing app? Generate a config skeleton from the data-help-ids already in
your markup, then just fill in the wording. Inline data-help-title / data-help-text values are
pre-filled when present.
help-layer scaffold --src ./src > helpConfig.js # one stub per id, to stdout
help-layer scaffold --src ./src --out helpConfig.js # ...or write a file
help-layer scaffold --src ./src --config ./helpConfig.js --export buildHelpConfig # only the missing idsOutput defaults to a JS module (export const helpConfig = { … }); use --format json for JSON.
Pass an existing --config to stub only the ids that aren't defined yet.
The CLI is static; for the live DOM (dynamic ids, SPA-mounted elements) call diagnose() on the
controller. It scans the current DOM, logs a grouped table, and returns a report object:
const help = initHelpLayer({ config, toggle: '#help', debug: true });
help.diagnose(); // logs + returns { bound, inline, missingConfig, unmatchedConfig, free, summary }With debug: true it's also exposed as window.helpLayerDiagnose() so you can run it straight from
the devtools console. It works whether help mode is ON or OFF. Use the CLI in CI, diagnose() while
debugging in the browser — they complement each other.
unmatchedConfigis the runtime counterpart of the CLI'sunusedConfig— a config key with no matching element (here, in the live DOM).
| Purpose | Command |
|---|---|
| Test | npm test |
| Lint / typecheck / all | npm run lint / npm run typecheck / npm run check |
| Run the demo | npm run demo (then open /demo/stress.html for the perf stress page) |
| Build the distribution | npm run build (emits ESM, IIFE, and type definitions to dist/) |
- Source: https://github.com/Y1-Effy/HelpLayer
- Issues & requests: https://github.com/Y1-Effy/HelpLayer/issues
- Changelog: CHANGELOG.md
- License: ISC
