Skip to main content
Urbicon UI

Guidebeta

A bidirectional in-app help system: a non-modal help panel, contextual hints, UI↔guide links, and an opt-in guided tour — all over one headless engine.

Playground

Billing

Plan
Pro — $29/mo
Seats
5 of 10 used
Placement
Size
<GuidePanel
  title="Help"
>
  <GuideArticle id="pg-plan" title="Billing & plans">
    <p>
      Your <GuideMention for="pg-plan">current plan</GuideMention> sets your monthly price and feature
      limits. Upgrade or downgrade at any time.
    </p>
    <p>
      Each <GuideMention for="pg-seats">seat</GuideMention> is one team member who can sign in.
      You are billed per occupied seat — see
      <GuideRef article="pg-seats">managing seats</GuideRef>.
    </p>
  </GuideArticle>
  <!--
    Der zweite Artikel ist das Ziel des `GuideRef` — und er macht die
    `pg-seats`-Mention oben erst gültig, die vorher ins Leere zeigte.
    `GuideRef` verlinkt *innerhalb* des Panels (Artikel → Artikel),
    `GuideMention` verbindet die UI mit dem Panel: die zwei Richtungen,
    die das Guide-System ausmachen.
  -->
  <GuideArticle id="pg-seats" title="Managing seats">
    <p>
      A seat frees up as soon as you remove a member. Billing follows on the next cycle, so
      removing someone mid-month does not refund the current one.
    </p>
    <p>
      Pricing per seat depends on your
      <GuideRef article="pg-plan">billing plan</GuideRef>.
    </p>
  </GuideArticle>
</GuidePanel>

01 Setup

Mount one GuideProvider near your app root. It instantiates a GuideController and shares it with every Guide surface via context. Mark any UI element a topic with data-guide="<id>"; tours, hints, markers, and mentions all resolve through that one namespace.

Provider + controller + a topic

<script lang="ts">
  import { GuideProvider, Guide, GuideController } from '@urbicon-ui/blocks';

  // Create the controller yourself for programmatic access (start tours, open the panel).
  const guide = new GuideController();
</script>

<GuideProvider controller={guide}>
  <!-- Any element becomes a guide target with a data-guide id -->
  <button data-guide="save-button">Save</button>

  <!-- Mount the tour renderer once; it stays invisible until a tour starts -->
  <Guide />
</GuideProvider>

<button onclick={() => guide.startTour(welcomeTour)}>Take the tour</button>

02 The help panel

The GuidePanel is a small help center living inside your product. Its index is nothing more than the GuideArticles you mount — and it scales with them: bucket articles into sections with group, switch on searchable when the catalog grows, and cross-link related articles with GuideRef. All three demos below open a real panel; close it with its × button or Escape.

Grouped sections

Open the help panel — articles that share a group render under one section header.
Atlas — Projects
<GuideProvider controller={groupsGuide}>
  <div
    class="border-border-subtle bg-surface-elevated flex w-full max-w-md items-center justify-between gap-3 rounded-2xl border px-4 py-3"
  >
    <span class="text-text-primary text-sm font-semibold">Atlas — Projects</span>
    <Button
      variant="outlined"
      intent="neutral"
      size="sm"
      onclick={() => groupsGuide.openPanel()}
    >
      Open help
    </Button>
  </div>
  <GuidePanel title="Help">
    <GuideArticle id="grp-first-project" title="Create a project" group="Getting started">
      <p>A project collects everything one team ships: tasks, docs, and milestones.</p>
    </GuideArticle>
    <GuideArticle id="grp-invite" title="Invite your team" group="Getting started">
      <p>Invite teammates by email — they join with access to every shared project.</p>
    </GuideArticle>
    <GuideArticle id="grp-plans" title="Plans & pricing" group="Billing">
      <p>The Free plan covers three projects. Pro removes the limit.</p>
    </GuideArticle>
    <GuideArticle id="grp-seats" title="Seats" group="Billing">
      <p>You are billed per occupied seat, prorated monthly.</p>
    </GuideArticle>
    <GuideArticle id="grp-shortcuts" title="Keyboard shortcuts">
      <p>Press <strong>?</strong> anywhere to see the full shortcut map.</p>
    </GuideArticle>
  </GuidePanel>
</GuideProvider>

Sections appear in the order their first article is defined; articles without a group (here: "Keyboard shortcuts") collect into one headerless block. When no article sets a group at all, the index stays a flat list — grouping is purely opt-in.

Searchable index

Open the panel and type “export” — the filter narrows the index while non-empty sections keep their headers.
Atlas — Settings
<GuideProvider controller={searchGuide}>
  <div
    class="border-border-subtle bg-surface-elevated flex w-full max-w-md items-center justify-between gap-3 rounded-2xl border px-4 py-3"
  >
    <span class="text-text-primary text-sm font-semibold">Atlas — Settings</span>
    <Button
      variant="outlined"
      intent="neutral"
      size="sm"
      onclick={() => searchGuide.openPanel()}
    >
      Open help
    </Button>
  </div>
  <GuidePanel title="Help" searchable>
    <GuideArticle id="srch-profile" title="Profile & avatar" group="Account">
      <p>Your name and avatar appear on comments and shared views.</p>
    </GuideArticle>
    <GuideArticle id="srch-security" title="Password & security" group="Account">
      <p>Change your password or add a passkey for phishing-resistant sign-in.</p>
    </GuideArticle>
    <GuideArticle id="srch-notifications" title="Notification preferences" group="Account">
      <p>Choose which events reach you by email, push, or in-app.</p>
    </GuideArticle>
    <GuideArticle id="srch-import" title="Import from CSV" group="Data">
      <p>Upload a CSV and map its columns to project fields.</p>
    </GuideArticle>
    <GuideArticle id="srch-export" title="Export your data" group="Data">
      <p>Download the current view as CSV or JSON at any time.</p>
    </GuideArticle>
    <GuideArticle id="srch-scheduled" title="Scheduled exports" group="Data">
      <p>Deliver a recurring export to email or webhook on a schedule.</p>
    </GuideArticle>
  </GuidePanel>
</GuideProvider>

The filter matches article titles case-insensitively and runs before grouping, so empty sections disappear and an empty result announces itself politely. Closing the panel resets the query — a reopen starts from the complete index. Search pairs naturally with grouping, but works on a flat list just as well.

Cross-linked articles

Open the pot article, then follow the inline references to jump between related articles.
Trip to Lisbon — €412.80
<GuideProvider controller={refGuide}>
  <div
    class="border-border-subtle bg-surface-elevated flex w-full max-w-md items-center justify-between gap-3 rounded-2xl border px-4 py-3"
  >
    <span class="text-text-primary text-sm font-semibold">Trip to Lisbon — €412.80</span>
    <Button
      variant="outlined"
      intent="neutral"
      size="sm"
      onclick={() => refGuide.openPanel('ref-pot')}
    >
      How is this split?
    </Button>
  </div>
  <GuidePanel title="Help">
    <GuideArticle id="ref-pot" title="The cost pot">
      <p>
        Every expense lands in the trip's shared pot. At the end, the pot is settled with as few
        transfers as possible, based on each expense's
        <GuideRef article="ref-splitting">splitting method</GuideRef>.
      </p>
    </GuideArticle>
    <GuideArticle id="ref-splitting" title="Splitting methods">
      <p>
        Split equally, by shares, or by exact amounts. The method applies per expense and feeds
        the <GuideRef article="ref-pot">cost pot</GuideRef>'s final balance.
      </p>
    </GuideArticle>
  </GuidePanel>
</GuideProvider>

A GuideRef navigates the open panel to another article — the help-internal analogue of GuideMention, which links out to a UI element instead. It resolves against the panel's article registry, so a ref pointing at an unknown id (or rendered outside a panel) degrades to plain text instead of a dead link. The panel's back button returns to the index from any article.

03 Contextual hints

A GuideHint waits at the right element instead of interrupting. Show it on mount, or drive it from your own route/condition with trigger="manual" and open. It persists "seen" so it appears once, and steps aside while a modal or tour is open.

A waiting hint

Toggle the hint anchored to the Export button.

New: scheduled exports

You can now export on a recurring schedule from here.
<GuideProvider controller={hintGuide}>
  <div class="flex flex-wrap items-center gap-3">
    <button
      data-guide="ex-export"
      class="border-border-default text-text-secondary rounded-lg border px-3 py-2 text-sm"
    >
      Export
    </button>
    <Button
      variant="outlined"
      intent="neutral"
      size="sm"
      onclick={() => (hintOpen = !hintOpen)}
    >
      {hintOpen ? 'Hide' : 'Show'} hint
    </Button>
  </div>
  <GuideHint
    for="ex-export"
    trigger="manual"
    open={hintOpen}
    once={false}
    title="New: scheduled exports"
    onDismiss={() => (hintOpen = false)}
  >
    You can now export on a recurring schedule from here.
  </GuideHint>
</GuideProvider>

04 Guided tour & beacon

The guided tour is the deliberately opt-in, intrusive surface: a spotlight that dims everything but the current step's target, plus an anchored bubble. A GuideBeacon is the gentle entry point — a waiting hotspot that starts the tour on click, the opposite of an auto-start.

Beacon-launched tour

Click the pulsing beacon, or the button, to start a two-step tour.

Dashboard

<GuideProvider controller={tourGuide}>
  <div class="border-border-subtle bg-surface-elevated rounded-2xl border p-6">
    <div class="mb-4 flex items-center justify-between">
      <p class="text-text-primary text-sm font-semibold">Dashboard</p>
      <span class="relative inline-flex">
        <GuideBeacon tour={demoTour} once={false} />
      </span>
    </div>
    <div class="flex flex-wrap gap-3">
      <button
        data-guide="tour-filters"
        class="border-border-default text-text-secondary rounded-lg border px-3 py-2 text-sm"
      >
        Filters
      </button>
      <button
        data-guide="tour-export"
        class="border-border-default text-text-secondary rounded-lg border px-3 py-2 text-sm"
      >
        Export
      </button>
    </div>
    <div class="mt-4">
      <Button
        variant="outlined"
        intent="neutral"
        size="sm"
        onclick={() => tourGuide.startTour(demoTour)}
      >
        Start tour
      </Button>
    </div>
  </div>
  <Guide />
</GuideProvider>

Tours survive client-side navigation: the controller lives in the layout's provider, an unresolved target renders centered over the full scrim, and the bubble re-anchors as soon as the new route's data-guide element appears. Give a step a route and wire a navigate hook, and the library drives the navigation declaratively — going to the step's route before the spotlight. A tour-internal navigation keeps the tour running; a foreign one (the user leaving) stops it (analytics-silent). prev() navigates back symmetrically. Keep Guide mounted in the layout (a route-local renderer unmounts on navigation and ends the tour). For routing chosen at runtime, navigate imperatively in onStep instead (a tour with no route is never second-guessed).

Cross-route tour (declarative step.route + navigate hook)

<script lang="ts">
  import { goto } from '$app/navigation';
  import { GuideController, type GuideTour } from '@urbicon-ui/blocks';

  // Wire the router once; the library stays framework-agnostic.
  // (Equivalently: <GuideProvider navigate={(route) => goto(route)}>.)
  const guide = new GuideController({ navigate: (route) => goto(route) });

  const tour: GuideTour = {
    id: 'cross-route-onboarding',
    steps: [
      { target: 'dash-overview', route: '/dashboard', title: 'Your dashboard', body: '' },
      { target: 'dash-filter', route: '/dashboard', title: 'Filter', body: '' },
      { target: 'billing-plan', route: '/settings/billing', title: 'Your plan', body: '' }
    ]
  };
</script>

05 Analytics

The real value of a tour is its funnel and drop-off signal. A GuideTour carries three optional hooks fired from the engine, so they trigger no matter which surface drives the tour. They are invoked defensively — a throwing handler never corrupts tour state. See GuideStepEvent / GuideEndEvent in the API reference for the payloads.

Wiring tour analytics

const welcomeTour: GuideTour = {
  id: 'welcome',
  steps: [
    { target: 'save-button', title: 'Save', body: 'Persist your changes here.' },
    { target: 'filter-control', title: 'Filter', body: 'Narrow the list.', interactive: true }
  ],
  // Fired on start (via: 'start') and every next/prev — the step-by-step funnel.
  onStep: ({ index, total, via }) =>
    analytics.track('tour_step', { tour: 'welcome', step: index + 1, total, via }),
  // Fired when the user finishes the whole tour.
  onComplete: () => analytics.track('tour_complete', { tour: 'welcome' }),
  // Fired when the user bails — event.index is where they dropped off.
  onSkip: ({ index }) => analytics.track('tour_skip', { tour: 'welcome', droppedAt: index })
};

06 The data-guide namespace

Every guide target is identified by a string id. There are two ways to register one — both feed the same registry, so a tour step, a hint, a marker, and a mention can all point at the same id.

Two ways to mark a target

<!-- 1. Declarative attribute — framework-agnostic, works on elements you don't render -->
<button data-guide="save-button">Save</button>

<!-- 2. Programmatic attachment — carries metadata (label, article, direction) -->
<button {@attach guide.target('save-button', {
  label: 'Save button',
  article: 'saving',          // which panel article the marker opens
  direction: 'both'           // 'to-guide' | 'to-ui' | 'both' — gates Marker vs Mention
})}>Save</button>

direction makes the bidirectional link a deliberate choice: 'to-ui' makes a GuideMarker inert (UI → guide off), 'to-guide' degrades a GuideMention to plain text (guide → UI off), and 'both' (the default) enables both. In DEV, a target id referenced but not found in the DOM logs a warning instead of failing silently.

07 Accessibility

Keyboard parity

Markers and mentions are real <button>s. A mention highlights its target on focus as well as hover, so the bidirectional link works without a mouse. The tour bubble takes focus on open; / step, Esc skips. An interactive step joins its spotlit target to the bubble in one Tab cycle.

Announcements & focus

The tour announces each step through a polite aria-live region (so the arrow-key path is never silent), and the hint announces itself with role="status". The tour returns focus to wherever it was when the tour ends.

Non-modal by design

The help panel has no focus trap and no backdrop — it coexists with the app (that is what lets a mention highlight a field behind it). Escape only closes it while focus is inside, so a foreground dialog keeps priority. Motion (panel slide, beacon pulse, step fade) honors prefers-reduced-motion.

08 Customization

Every surface supports unstyled, per-slot slotClasses, and named presets. Two tokens tune the tour's spotlight scrim and the additive highlight ring.

Tokens & slot overrides

/* Tune globally via the design tokens (defaults shown — override to taste) */
:root {
  --blocks-guide-scrim: oklch(0 0 0 / 0.5);            /* the tour's dimming backdrop */
  --blocks-guide-highlight-ring: var(--color-primary); /* the additive Mention→UI ring */
}

/* Or override per instance */
<GuidePanel slotClasses={{ panel: 'w-[28rem]', header: 'bg-surface-subtle' }} />
<GuideHint slotClasses={{ hint: 'max-w-sm' }} />

09 API Reference

9 surfaces

Guide (tour renderer)

Mount once inside GuideProvider; renders nothing until a tour starts.

8 props
Prop
Type
Default
Description

GuideController

The headless engine. Create one and pass it to the provider for programmatic control.

9 props
Prop
Type
Default
Description

GuideTour

The tour definition you pass to startTour — lives in your app, not the library.

6 props 2 required
Prop
Type
Default
Description

GuideStep

7 props
Prop
Type
Default
Description

GuideStepEvent / GuideEndEvent

The payloads passed to the analytics hooks.

5 props
Prop
Type
Default
Description

GuideProvider

Context root — wires every surface to one GuideController.

4 props 1 required
Prop
Type
Default
Description

GuidePanel

The callable, non-modal help panel (D1).

12 props
Prop
Type
Default
Description

GuideArticle

A structured help article inside the panel.

8 props 2 required
Prop
Type
Default
Description

GuideMarker

Direction A — the discreet "ⓘ" trigger that opens the panel at an article.

10 props
Prop
Type
Default
Description

GuideMention

Direction B — inline article→UI reference that highlights the element.

8 props 1 required
Prop
Type
Default
Description

GuideRef

Inline article→article link — navigates the panel to another article.

6 props 1 required
Prop
Type
Default
Description

GuideHint

Contextual, waiting hint anchored to a data-guide element.

14 props 1 required
Prop
Type
Default
Description

GuideBeacon

Waiting, pulsing hotspot that starts an opt-in tour.

9 props
Prop
Type
Default
Description

10 Types

Local type definitions used by this component.

39 types
Name
Kind
Category
Used by
Description

11 Installation

Import

import {
  GuideProvider,
  GuidePanel,
  GuideArticle,
  GuideMarker,
  GuideMention,
  GuideRef,
  GuideHint,
  Guide,
  GuideBeacon,
  GuideController
} from '@urbicon-ui/blocks';
import type { GuideTour, GuideStep, GuideStepEvent, GuideEndEvent } from '@urbicon-ui/blocks';