@sendsay-ru/guidely
v0.7.2
Published
Framework-agnostic onboarding library core.
Maintainers
Readme
@sendsay-ru/guidely
Framework-agnostic onboarding library core for TypeScript applications.
@sendsay-ru/guidely includes config validation, the tour engine, a Shadow DOM renderer, theme
customization, localization, tooltip positioning, modal steps, hotspot beacons, a contextual
auto-promotion scheduler, and flows that start by themselves when the app opens, when an element
appears, or when the user opens a matching page.
Install
npm install @sendsay-ru/guidelyyarn add @sendsay-ru/guidelyUsage
import { createGuidely, type TFlowState } from "@sendsay-ru/guidely";
const stateByFlow = new Map<string, TFlowState>();
const guidely = createGuidely({
config: {
version: 1,
flows: [
{
id: "welcome-tour",
startsAt: "2026-08-01T00:00:00.000Z",
steps: [
{
id: "welcome",
type: "modal",
content: {
en: { title: "Welcome", body: "Let us show you around." },
},
},
{
id: "profile",
type: "tooltip",
target: { type: "data-id", value: "profile-button" },
placement: "bottom",
content: {
en: { title: "Profile", body: "Manage your account here." },
},
},
],
},
],
},
adapter: {
getState(flowId, context) {
// Use context.startsAt if your app gates tours by date.
return stateByFlow.get(flowId) ?? null;
},
setState(flowId, state) {
stateByFlow.set(flowId, state);
},
},
});
await guidely.start("welcome-tour", { force: true });Targets with { type: "data-id", value: "profile-button" } resolve to
data-guidely-id="profile-button" by default. The attribute prefix can be changed through
attributePrefix in the config.
Use scope: "document" or scope: "frame" when more than one resolver can contain the same
target id. Unscoped targets remain supported for backwards compatibility.
Guidely also accepts a custom targetResolver at runtime. Resolvers turn authored target config
into an abstract resolved target API, so the renderer does not need direct access to an
HTMLElement. This is useful when a target lives in another document, a strict iframe, or any
surface where the host app can provide geometry and events without exposing DOM nodes.
Hotspot steps render a beacon marker and keep the tooltip closed until the user clicks, focuses,
or hovers the marker. Add initiallyOpen: true to a hotspot step to show the tooltip immediately.
Keyboard and Focus
Modal and tooltip steps block the rest of the page, so they move focus into the step and keep Tab
inside it. Escape skips such a step only when its skip action is enabled.
Hotspot steps leave the page interactive. Tab keeps its normal order, hovering or focusing the beacon expands the hint without moving focus, clicking the beacon moves focus into the hint, and Escape collapses the hint back to the beacon without ending the flow.
Guidely handles Escape, Enter, and Space only when they come from its own UI, so host shortcuts keep working. When a step closes, focus goes back to the element that had it before the step only if focus is still inside Guidely, or if a modal or tooltip step dropped it on the page body. Focus the user has moved elsewhere in the page stays where it is.
A renderer created with createRenderer({ manageFocus: false }) never moves, traps, or restores
focus. Visual editors use it for previews that update while the author types elsewhere.
Contextual Auto-promotion
Set a flow trigger to { type: "target" } to let Guidely promote it when its first-step target
becomes available. The scheduler owns target discovery, priority, one shared prompt slot, prompt
lease, availability-cycle deduplication, cooldown, and optional session frequency limits. The host
only supplies config, a state adapter, and optional target resolvers.
const config = {
version: 1,
autoPromotion: {
promptLeaseMs: 15_000,
cooldownMs: 60_000,
minIntervalMs: 1_000,
},
flows: [
{
id: "new-toolbar",
trigger: { type: "target", priority: 10 },
steps: [
{
id: "toolbar",
type: "hotspot",
target: { type: "data-id", value: "toolbar", scope: "frame" },
content: { en: { body: "Try the new toolbar." } },
},
],
},
],
};Only one prompt beacon is rendered at a time. A prompt is discovery UI, not an active flow:
snapshot.promptedFlow is set, activeFlow stays null, and no in_progress state is persisted.
Clicking, focusing, or hovering the beacon starts the flow normally. If the prompt is ignored until
promptLeaseMs, the slot can move to another eligible flow. That candidate is considered consumed
for the current continuous target-availability cycle: expiration of cooldownMs alone does not
show it again. When the target becomes unavailable and later reappears, the candidate is rearmed;
cooldownMs still prevents an immediate repeat. Target loss removes the prompt immediately and
starts the same cooldown. Completed, skipped, and not-applicable flows are never promoted.
maxPromptsPerSession is an optional hard cap across all candidates for the lifetime of one
Guidely runtime. Omit it when targets can first appear later in the session; availability-cycle
deduplication prevents continuously visible targets from endlessly reclaiming the prompt slot.
Eligibility is read from the state adapter once per candidate during a scheduler session and is
updated immediately when the same Guidely instance completes or skips a flow. For efficient DOM
discovery, data-id targets use one shared observer that filters to the configured data attribute
and relevant inserted subtrees. CSS selectors require conservative attribute observation, so
prefer data-id targets for contextual auto-promotion.
If a promoted flow is stopped or fails to start, that candidate stays consumed for its current availability cycle and enters its configured cooldown instead of immediately reclaiming the prompt slot.
Release Announcements
Set a flow trigger to { type: "immediate" } to open it right after the runtime starts, for
example a modal that introduces the features of a recent release. Guidely decides once per runtime
session, before it shows any contextual prompt:
- immediate flows are ordered by
startsAt, newest first; - flows for which the adapter returns
"not_applicable"are passed over, so the host decides which announcements apply to the user; - the freshest remaining flow starts, or resumes when it is still
in_progress; - if that flow is already completed or skipped, nothing starts. Older announcements count as superseded, so they never play one after another, now or on later visits.
Flows without a valid startsAt count as the oldest. With autoPromote: false, immediate flows do
not start automatically, the same as contextual prompts.
Announcements for one part of the app
An announcement about one screen, or about an app inside an iframe, should start when the user
opens it rather than when the runtime starts. Give the trigger a target: the flow then starts
once that element appears, for example a toolbar of the email editor in the frame scope:
trigger: {
type: "immediate",
target: { type: "data-id", value: "editor-toolbar", scope: "frame" },
}Flows with the same target form their own group, and the rules above apply to each group separately: an editor announcement is not superseded by a newer release of the whole app. The decision is made again each time the element appears, so a user who leaves the editor before an announcement could start sees it next time. When announcements of several groups are due at once, the freshest starts first and the others follow after it ends.
const config = {
version: 1,
flows: [
{
id: "release-2026-09",
startsAt: "2026-09-01T00:00:00.000Z",
trigger: { type: "immediate" },
steps: [
{
id: "blocks",
type: "modal",
content: {
en: {
title: "New multi-section blocks",
body: "Build complex email designs faster.",
image: { src: "/release/2026-09/blocks.png", alt: "Blocks catalog" },
},
},
},
],
},
],
};Modal steps render an optional image from the localized content, one progress dot per step in
multi-step flows, the title and body, and the step actions. In multi-step flows the skip action is
a close button; single-step modals leave it out and are dismissed with their primary button. The
back button is hidden on the first step. Line breaks in a modal title and body are kept. Use
labels in the localized content to rename the buttons of one step, for example
labels: { done: "Got it" }.
Route Tours
Set a flow trigger to { type: "route", path } to start it when the user opens a matching page,
for example tips for the campaign page:
trigger: { type: "route", path: "/campaigns/:id" }Guidely does not watch the address bar: the application reports the page it shows. Pass the current
path as path when you create Guidely, and call setPath after each navigation. React applications
pass the path prop of GuidelyProvider instead. Report the path your router matches, so hash and
memory routers work too.
const guidely = createGuidely({ config, adapter, path: location.pathname });
router.subscribe((location) => {
guidely.setPath(location.pathname);
});Paths use the pattern syntax of react-router:
/campaignsmatches that page only;:idmatches any one segment, so/campaigns/:idmatches/campaigns/42, but not/campaignsor/campaigns/42/edit;- a final
*matches the rest of the path, including nothing, so/campaigns/*matches/campaignsand every page under it.
Letter case, percent-encoding, and a trailing slash do not matter, and the query and hash are not
part of a path. The config schema rejects paths that do not start with /, paths with ? or #,
and a * anywhere but at the end. matchRoutePath(path, pathname) applies the same rules, for
example in tests.
Route flows follow the rules of release announcements. Flows with the same path form a group, and
paths that match the same pages count as the same, so /campaigns/:id and /Campaigns/:slug/
share a group:
- the freshest applicable flow of the group starts, and older ones count as superseded once it is completed or skipped;
- the decision is made on each visit, that is, when the user comes to a matching page from a page that does not match. Moving between matching pages continues the visit;
- a flow that cannot start because another flow is running starts after that flow ends, if the user is still on a matching page;
- leaving the page does not stop a flow that has started. Steps with targets stop by themselves when their target disappears.
Route flows do not start until a path is reported, and with autoPromote: false they do not start
at all, the same as immediate flows and contextual prompts.
Config Shape
Guidely configs are validated at runtime with parseConfig. Defaults are applied by the schema,
so you can omit optional fields in authored configs.
Type configs you write by hand with TGuidelyConfigInput, where fields with defaults are optional:
import type { TGuidelyConfigInput } from "@sendsay-ru/guidely";
export const config = {
version: 1,
flows: [
{
id: "release",
trigger: { type: "immediate" },
steps: [{ id: "news", type: "modal", content: { en: { body: "What's new" } } }],
},
],
} satisfies TGuidelyConfigInput;TGuidelyConfig is the parsed shape that parseConfig returns: every default is filled in there,
so it also requires fields a step does not use, such as placement of a modal.
The authored shape:
type TGuidelyConfigInput = {
version: 1;
defaultLocale?: string; // default: "en"
attributePrefix?: string; // default: "guidely"
autoPromotion?: {
promptLeaseMs?: number; // default: 15000
cooldownMs?: number; // default: 60000
minIntervalMs?: number; // default: 1000
maxPromptsPerSession?: number;
};
flows: TFlow[];
};
type TFlow = {
id: string;
startsAt?: string; // optional flow metadata passed to adapter.getState
trigger?:
| { type: "manual" }
| { type: "route"; path: string } // a page path pattern, see Route Tours
| { type: "target"; priority?: number }
| { type: "immediate"; target?: TTarget }; // default: manual; priority default: 0
highlight?: {
mode?: "blocking" | "interactive"; // default: blocking
padding?: number; // default: 8
};
steps: TStep[];
};
type TTarget = {
type: "data-id" | "css";
value: string;
scope?: string;
};
type TStep = {
id: string;
type: "modal" | "tooltip" | "hotspot";
target?: TTarget; // required for tooltip and hotspot
placement?: "top" | "bottom" | "left" | "right" | "auto"; // default: auto
initiallyOpen?: boolean; // hotspot only, default: false
content: Record<
string,
{
title?: string;
body: string;
image?: { src: string; alt?: string }; // shown by modal steps
labels?: { back?: string; next?: string; skip?: string; done?: string; close?: string };
}
>;
actions?: {
prev?: boolean; // default: false
next?: boolean; // default: true
skip?: boolean; // default: true
};
advanceOn?: { event: "click" | "input" | "change" | "submit" };
waitForTarget?: {
timeout?: number; // default: 5000
onTimeout?: "skip" | "abort"; // default: skip
};
};Use advanceOn when a step should advance after an event on the real target element. Use
waitForTarget when a target may appear later after routing, lazy rendering, or data loading.
API
const guidely = createGuidely({
config,
adapter,
onEvent,
locale,
labels,
theme,
container,
targetResolver,
autoPromote, // set false to disable automatic prompts while keeping manual starts available
path, // the page the user is on, for route triggers
});
const result = await guidely.start(flowId, { force, startAtStep });
// { started: true, resumed: boolean }
// or { started: false, reason: "completed" | "skipped" | "not_applicable" | "destroyed" }
guidely.next();
guidely.prev();
guidely.skip();
guidely.complete();
guidely.goTo(stepId);
guidely.stop();
guidely.setLocale(locale);
guidely.setPath(path); // after the user moves to another page
guidely.getActiveFlow();
guidely.subscribe(listener);
guidely.subscribeEvents(listener);
guidely.getSnapshot();
guidely.destroy();Custom Target Resolvers
The default resolver supports data-guidely-id targets and CSS selectors in the current
document. A custom resolver can add another target source while keeping the default DOM resolver
available as a fallback.
The default resolver returns live targets: they stay bound to the target config rather than to one
element, so geometry, advanceOn events, and focus follow an element that the application
re-mounts with the same id. Target events are delegated from the document in the capture phase.
Custom resolvers can reuse this behavior by delegating DOM targets to the exported
domTargetResolver, while createDomResolvedTarget(element, target) stays bound to the element it
receives.
Only rendered elements count as DOM targets: an element needs a non-empty layout box and must not
be hidden with visibility. When several elements match, the first rendered one is used and kept
while it stays rendered, so a highlighted target does not jump. Showing or hiding a target with
display is detected through ResizeObserver. Opacity is ignored on purpose, so controls revealed
on hover remain valid targets. A target hidden while its step is shown counts as lost, the same as
a removed one. The step stops only if the target stays lost for 300 ms, so an element that the
application re-mounts or briefly hides keeps its step. Stopping keeps the persisted in_progress
state.
import { createGuidely, type TTargetResolver } from "@sendsay-ru/guidely";
const targetResolver: TTargetResolver = {
resolve(target) {
if (target.type !== "data-id" || target.value !== "remote-button") {
return null;
}
return {
target,
getRect: () => ({
top: 80,
right: 220,
bottom: 120,
left: 120,
width: 100,
height: 40,
}),
watchRect: (onChange) => {
// Subscribe to whatever moves or resizes the remote target.
return () => undefined;
},
subscribeEvent: (eventName, handler) => {
// Subscribe to target events such as advanceOn click/input/change/submit.
return () => undefined;
},
};
},
};
const guidely = createGuidely({
config,
adapter,
targetResolver,
});Resolved targets implement:
type TResolvedTarget = {
getRect: () => TRect | null;
watchRect: (onChange: () => void) => () => void;
subscribeEvent: (eventName: string, handler: () => void) => () => void;
focus?: () => void;
metadata?: Record<string, unknown>;
};Resolvers can also implement watchAvailability(target, context, onChange). It must report the
initial availability and then call onChange(isAvailable) only when the target becomes available
or unavailable.
Availability must not be coupled to rectangle movement. watchRect is created only while a target
is actively rendered.
Resolvers that own targets the page cannot reach, such as iframe elements, can also implement
startPicking(listener). Visual editors call it to let authors pick such targets: the resolver
reports hovered and picked elements through listener.onHover, listener.onPick, and
listener.onCancel, and returns a function that stops picking. createCompositeTargetResolver
forwards picking to every resolver that supports it.
For cross-origin iframe targets, use @sendsay-ru/guidely-frame-bridge, which implements this
resolver API over postMessage.
State Adapter
Guidely delegates persistence to the host application:
type TGuidelyStateAdapter = {
getState: (
flowId: string,
context: TGuidelyStateAdapterContext,
) => TFlowState | null | Promise<TFlowState | null>;
setState: (flowId: string, state: TFlowState) => void | Promise<void>;
};
type TGuidelyStateAdapterContext = {
startsAt?: string;
};This keeps the core free of storage assumptions. You can use memory, local storage, a server API, or an existing product state layer.
Guidely waits for getState before starting a flow, so eligibility and persisted terminal states are
resolved before anything is rendered. Once the flow is eligible, Guidely renders it immediately and
queues the initial in_progress state without waiting for asynchronous setState to finish. A
failed write emits an error event but does not hide or roll back the active flow.
setState calls are serialized per flow, so asynchronous adapters cannot persist rapid step
transitions in reverse order. While an asynchronous state read in start() is in progress, another
start() returns { started: false, reason: "not_ready" }.
startsAt is optional flow metadata and is passed to adapter.getState as
context.startsAt. The host application can use it to decide whether to return stored state,
null, or a "not_applicable" flow state before Guidely renders anything.
TFlowState.status can be "not_started", "in_progress", "completed", "skipped", or
"not_applicable". Use "not_applicable" when the host application has determined that a flow
does not apply to the current user or context. Guidely will not start completed, skipped, or not
applicable flows unless start is called with force: true.
Events
The library does not send telemetry. Pass onEvent during creation or call
subscribeEvents(listener) to receive runtime events such as flow:prompt,
flow:prompt-engage, flow:prompt-dismiss, flow:start, flow:complete, flow:skip,
step:show, step:advance, step:target-not-found, and error.
flow:start includes source: "manual" for start() calls, "prompt" when the user engaged a
prompt, "immediate" for flows with an immediate trigger, and "route" for flows with a route
trigger.
Theming and Localization
Pass theme to override colors, radii, spacing, typography, tooltip, and beacon tokens. Pass
labels to override system button labels, or labels in localized step content to change them for
one step. Step content falls back from active locale to
defaultLocale and then to the first available localized entry.
Theme groups are merged with defaults by individual fields:
import type { TThemeOverride } from "@sendsay-ru/guidely";
const theme = {
colors: {
primary: "#2563eb",
onPrimary: "#ffffff",
surface: "#ffffff",
onSurface: "#111827",
overlay: "rgba(17, 24, 39, 0.56)",
beacon: "#f59e0b",
secondary: "#f1f5f9",
muted: "#94a3b8",
subtle: "#e2e8f0",
},
radius: {
sm: "4px",
md: "8px",
lg: "12px",
},
spacing: {
sm: "8px",
md: "16px",
lg: "24px",
},
typography: {
fontFamily:
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontSize: "14px",
},
zIndex: 2147483000,
tooltip: {
maxWidth: "320px",
arrowSize: "8px",
},
beacon: {
size: "16px",
pulse: true,
},
} satisfies TThemeOverride;The default typography uses a system font stack to avoid invisible text while webfonts load. If
you override theme.typography.fontFamily with a custom webfont, define that font in the host
application with font-display: swap or optional.
