Skip to content

Y1-Effy/HelpLayer

Repository files navigation

HelpLayer

npm license repo coverage

English | 日本語

🔗 Live demo: https://y1-effy.github.io/HelpLayer/ (Vanilla / React / Vue — turn on "Help mode" top-right, then click an "i")

HelpLayer demo: turning Help mode ON makes a marker (here "i", "?" by default) appear next to each target element; clicking it opens a description popup. Turning it OFF removes all the DOM it added.

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, and Esc closes 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)

Table of contents

Why HelpLayer (vs. existing options)

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.

Installation

npm install help-layer

If 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'.

Quick start

1. Define targets with a config object

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.' },
  },
});

2. Write it inline in your markup (no config needed)

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' });

Use it with just a <script> (no bundler)

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 integrity hash 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.)

When it fits (where adoption pays off)

  • 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-text or 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.

Free placement (descriptions not bound to an element)

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 } },
}

API

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

Options

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

Callbacks

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, so onClose fires once.

Line breaks & links in the body

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 by render is inserted as-is and is not sanitized by the library. If you use untrusted data (e.g. user input), don't build it with innerHTML — use textContent, or neutralize it with something like DOMPurify before returning it (to prevent XSS). The default (no render) title/text rendering uses textContent, so it is safe.

Theming (CSS custom properties)

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

Browser & runtime support

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)

Module formats

  • ESM (default). import { initHelpLayer } from 'help-layer' resolves to the prebuilt, tested dist/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 global HelpLayer — see Use it with just a <script>.
  • CommonJS (require). No require entry 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.

Node.js requirement

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.

Known limitations

  • 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: the MutationObserver sees the host's childList, 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 — call update(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 inert a11y isolation, the injected <style>, the window.helpLayerDiagnose global), so initializing a second one while another is still live logs a warning. Call destroy() on the previous instance before re-initializing. (Separate documents/iframes are independent.)
  • A custom --help-layer-marker-size is 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 or update(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 the data-help-id attribute on an existing element, rewriting data-help-title / data-help-text, or toggling state like disabled won't update the markers. This is intentional — watching every attribute mutation across the whole document (MutationObserver with attributes: 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 via update(config), toggle the mode OFF→ON, or re-insert the target element into the DOM (re-insertion is picked up by the childList observation).

Performance (how many markers)

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.

Accessibility

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:

  • inert can'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).
  • inert is broadly supported in current browsers; on engines without it, the visual/keyboard blocking still applies, but AT exclusion degrades gracefully (no error).

Security

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 / text are rendered with textContent only; innerHTML / eval / new Function are 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 render option. 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/document in 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."

Content Security Policy (CSP)

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.

Config audit CLI (help-layer check)

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 under unusedConfig (a warning, not an error).

Scaffold a config from your markup (help-layer scaffold)

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 ids

Output 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.

Runtime diagnostics (controller.diagnose())

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.

unmatchedConfig is the runtime counterpart of the CLI's unusedConfig — a config key with no matching element (here, in the live DOM).

Development

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/)

Repository

About

A lightweight, framework-agnostic help mode library that adds contextual guidance without modifying existing event handlers.

Topics

Resources

License

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors