Blocks
Product Tour Builder Block
Activation UIA responsive product-tour authoring workspace with a live app canvas, target autocomplete, tooltip placement controls, audience rules, launch scheduling, and publish checks.
Conversion / Product Tours
Product tour builder
Copy this into onboarding tools, SaaS admin products, customer education surfaces, feature adoption platforms, or internal growth consoles where teams need to build in-app tours before release.
1200px
<script setup>
import { computed, ref } from 'vue';
import {
DomAutocomplete,
DomBadge,
DomButton,
DomColorInput,
DomDatePicker,
DomDialog,
DomPositionInput,
DomRadioGroup,
DomStatusPill,
DomTagCombobox,
DomToggleButtonGroup,
} from '@getdom/studio/vue';
import LaunchChecklist from '../components/LaunchChecklist.vue';
import ProductTourCanvas from '../components/ProductTourCanvas.vue';
import TourStepRail from '../components/TourStepRail.vue';
const goalOptions = [
{
label: 'Activation',
value: 'activation',
description: 'Help new workspaces finish the setup path.',
metric: 'Aha moment',
},
{
label: 'Adoption',
value: 'adoption',
description: 'Introduce a released feature to qualified accounts.',
metric: 'Feature usage',
},
{
label: 'Expansion',
value: 'expansion',
description: 'Guide qualified teams toward paid capabilities.',
metric: 'Upgrade intent',
},
];
const targetOptions = [
{ label: 'Setup checklist card', value: '[data-tour="setup-card"]', id: 'setup-card', description: 'Dashboard home / checklist container', screen: 'Home' },
{ label: 'Invite team button', value: '[data-tour="invite-button"]', id: 'invite-button', description: 'Primary invite action in top bar', screen: 'Home' },
{ label: 'Connect data tile', value: '[data-tour="connect-data"]', id: 'connect-data', description: 'Data source setup card', screen: 'Home' },
{ label: 'Publish workflow button', value: '[data-tour="publish-workflow"]', id: 'publish-workflow', description: 'Workflow launch CTA', screen: 'Home' },
{ label: 'Usage insights tab', value: '[data-tour="usage-insights"]', id: 'setup-card', description: 'Analytics tab for activated accounts', screen: 'Insights' },
];
const audienceOptions = [
{ label: 'Trial admins', value: 'trial-admins', description: 'Workspace owners inside the first 14 days', count: '12.4k' },
{ label: 'No connected source', value: 'no-connected-source', description: 'Workspaces missing their first data connection', count: '8.1k' },
{ label: 'Invited no project', value: 'invited-no-project', description: 'Accepted invite but has not started setup', count: '6k' },
{ label: 'Starter owners', value: 'starter-owners', description: 'Paid self-serve accounts with upgrade signals', count: '3.8k' },
{ label: 'Recently dismissed', value: 'recently-dismissed', description: 'Suppression segment for people who opted out', count: '-1.2k' },
];
const previewModeOptions = [
{ label: 'Desktop', value: 'desktop' },
{ label: 'Mobile', value: 'mobile' },
];
const triggerOptions = [
{ label: 'Page load', value: 'page-load' },
{ label: 'Intent', value: 'intent' },
{ label: 'Manual', value: 'manual' },
];
const steps = ref([
{
id: 'step-setup',
title: 'Show setup progress',
body: 'Start with the setup checklist so new admins can see what is left before activation.',
cta: 'Show me',
targetId: 'setup-card',
targetLabel: 'Setup checklist card',
targetSelector: '[data-tour="setup-card"]',
targetQuery: 'Setup checklist card',
placement: 'bottom-start',
status: 'Ready',
},
{
id: 'step-connect',
title: 'Connect source data',
body: 'Point teams to the data source that unlocks dashboards, automations, and account health.',
cta: 'Connect source',
targetId: 'connect-data',
targetLabel: 'Connect data tile',
targetSelector: '[data-tour="connect-data"]',
targetQuery: 'Connect data tile',
placement: 'start-top',
status: 'Needs QA',
},
{
id: 'step-invite',
title: 'Invite collaborators',
body: 'Ask admins to bring reviewers into the workspace before they publish their first workflow.',
cta: 'Invite team',
targetId: 'invite-button',
targetLabel: 'Invite team button',
targetSelector: '[data-tour="invite-button"]',
targetQuery: 'Invite team button',
placement: 'bottom-end',
status: 'Draft',
},
{
id: 'step-publish',
title: 'Publish the first workflow',
body: 'Close the loop by guiding activated teams to ship their first automated workflow.',
cta: 'Publish',
targetId: 'publish-workflow',
targetLabel: 'Publish workflow button',
targetSelector: '[data-tour="publish-workflow"]',
targetQuery: 'Publish workflow button',
placement: 'top-start',
status: 'Ready',
},
]);
const activeStepId = ref('step-connect');
const goal = ref('activation');
const previewMode = ref('desktop');
const triggerMode = ref('page-load');
const accentColor = ref('#2563eb');
const launchDate = ref('2026-06-19');
const audiences = ref(['trial-admins', 'no-connected-source']);
const publishDialogOpen = ref(false);
const publishState = ref('Draft saved 3 minutes ago');
const activeStep = computed(() => steps.value.find((step) => step.id === activeStepId.value) || steps.value[0]);
const selectedGoal = computed(() => goalOptions.find((option) => option.value === goal.value) || goalOptions[0]);
const selectedAudienceRecords = computed(() => audiences.value.map((value) => audienceOptions.find((option) => option.value === value)).filter(Boolean));
const estimatedReach = computed(() => {
return selectedAudienceRecords.value.reduce((total, audience) => {
const numericCount = Number.parseFloat(String(audience.count).replace(/[^\d.-]/g, ''));
if (!Number.isFinite(numericCount)) return total;
return total + (audience.count.includes('k') ? numericCount * 1000 : numericCount);
}, 0);
});
const completedSteps = computed(() => steps.value.filter((step) => step.status === 'Ready').length);
const checks = computed(() => [
{
id: 'selectors',
label: 'Target selectors resolve',
status: steps.value.every((step) => step.targetSelector) ? 'Passed' : 'Blocked',
detail: `${steps.value.filter((step) => step.targetSelector).length} selectors mapped to app surfaces.`,
},
{
id: 'placement',
label: 'Tooltip placement reviewed',
status: steps.value.some((step) => step.status === 'Needs QA') ? 'Warning' : 'Passed',
detail: steps.value.some((step) => step.status === 'Needs QA') ? 'One tooltip needs mobile collision QA.' : 'All placements have passed QA.',
},
{
id: 'approval',
label: 'All steps approved',
status: steps.value.every((step) => step.status === 'Ready') ? 'Passed' : 'Warning',
detail: `${completedSteps.value} of ${steps.value.length} steps are marked ready.`,
},
{
id: 'audience',
label: 'Audience and suppression set',
status: audiences.value.length >= 2 ? 'Passed' : 'Warning',
detail: `${selectedAudienceRecords.value.length} audience rules selected with ${(estimatedReach.value / 1000).toFixed(1)}k estimated reach.`,
},
{
id: 'schedule',
label: 'Launch schedule selected',
status: launchDate.value ? 'Passed' : 'Blocked',
detail: launchDate.value ? `Scheduled for ${launchDate.value}.` : 'Pick a launch date before publishing.',
},
]);
const readyToPublish = computed(() => checks.value.every((check) => check.status === 'Passed'));
const launchPayload = computed(() => ({
tourId: 'tour_activation_v4',
goal: goal.value,
accentColor: accentColor.value,
launchDate: launchDate.value,
trigger: triggerMode.value,
audiences: audiences.value,
steps: steps.value.map((step, index) => ({
order: index + 1,
title: step.title,
targetSelector: step.targetSelector,
placement: step.placement,
cta: step.cta,
status: step.status,
})),
}));
function selectStep(stepId) {
activeStepId.value = stepId;
publishState.value = 'Step selected';
}
function selectTarget(target) {
activeStep.value.targetId = target.id;
activeStep.value.targetLabel = target.label;
activeStep.value.targetQuery = target.label;
activeStep.value.targetSelector = target.value || `[data-tour="${target.id}"]`;
activeStep.value.status = 'Needs QA';
publishState.value = 'Target changed';
}
function selectCanvasTarget(target) {
selectTarget({
...target,
value: `[data-tour="${target.id}"]`,
});
}
function applyAutocompleteTarget(event) {
if (!event.item) return;
selectTarget(event.item);
}
function markStepReady() {
activeStep.value.status = 'Ready';
publishState.value = 'Step marked ready';
}
function saveDraft() {
publishState.value = 'Draft saved just now';
}
function publishTour() {
publishState.value = readyToPublish.value ? 'Tour scheduled' : 'Review checks before publishing';
if (readyToPublish.value) publishDialogOpen.value = false;
}
</script>
<template>
<section class="min-h-screen bg-canvas text-canvas-fg">
<div class="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-4 sm:px-6 lg:px-8">
<header class="rounded-lg border border-border skin-card px-4 py-4 sm:px-5">
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<DomStatusPill tone="info" size="sm">Draft tour</DomStatusPill>
<DomBadge tone="neutral" variant="outline">{{ publishState }}</DomBadge>
</div>
<h1 class="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl">Product tour builder</h1>
<p class="mt-2 max-w-3xl text-sm leading-6 text-muted-fg">
Build guided onboarding moments in context, tune tooltip placement, target the right accounts, and clear launch checks before release.
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<DomButton variant="secondary" @click="saveDraft">Save draft</DomButton>
<DomButton :disabled="!readyToPublish" @click="publishDialogOpen = true">Publish tour</DomButton>
</div>
</div>
</header>
<TourStepRail :steps="steps" :active-step-id="activeStepId" @select="selectStep" />
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]">
<div class="grid min-w-0 gap-4">
<ProductTourCanvas
:step="activeStep"
:accent-color="accentColor"
:preview-mode="previewMode"
@select-target="selectCanvasTarget"
/>
<section class="rounded-lg border border-border skin-card p-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-muted-fg">Step inspector</p>
<h2 class="mt-1 text-lg font-semibold text-canvas-fg">{{ activeStep.title }}</h2>
<p class="mt-1 text-sm leading-6 text-muted-fg">Edit the selected moment, then mark it ready when placement, copy, and target behavior are reviewed.</p>
</div>
<DomToggleButtonGroup
v-model="previewMode"
label="Preview mode"
:options="previewModeOptions"
size="sm"
chrome="none"
/>
</div>
<div class="mt-5 grid gap-5 lg:grid-cols-[minmax(0,1fr)_16rem]">
<div class="grid gap-4">
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.12em] text-muted-fg">Step title</span>
<input
v-model="activeStep.title"
class="mt-1 block h-10 w-full rounded-lg border border-input bg-canvas px-3 text-sm outline-none transition focus:border-ring focus:ring-2 focus:ring-ring/25"
/>
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.12em] text-muted-fg">Tooltip body</span>
<textarea
v-model="activeStep.body"
rows="4"
class="mt-1 block w-full resize-none rounded-lg border border-input bg-canvas px-3 py-2 text-sm leading-6 outline-none transition focus:border-ring focus:ring-2 focus:ring-ring/25"
></textarea>
</label>
<div class="grid gap-4 md:grid-cols-2">
<DomAutocomplete
v-model="activeStep.targetQuery"
label="Target element"
placeholder="Search app target..."
:options="targetOptions"
@select="applyAutocompleteTarget"
>
<template #item="{ item }">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-canvas-fg">{{ item.label }}</p>
<p class="mt-0.5 text-xs text-muted-fg">{{ item.description }}</p>
</div>
<span class="rounded-full bg-secondary px-2 py-0.5 text-[11px] font-semibold text-muted-fg">{{ item.screen }}</span>
</div>
</template>
</DomAutocomplete>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.12em] text-muted-fg">CTA label</span>
<input
v-model="activeStep.cta"
class="mt-1 block h-10 w-full rounded-lg border border-input bg-canvas px-3 text-sm outline-none transition focus:border-ring focus:ring-2 focus:ring-ring/25"
/>
</label>
</div>
<DomToggleButtonGroup
v-model="triggerMode"
label="Trigger"
:options="triggerOptions"
size="sm"
chrome="none"
/>
</div>
<div class="grid gap-4">
<DomPositionInput
v-model="activeStep.placement"
label="Tooltip placement"
description="Pick the preferred anchor position around the selected target."
@update:model-value="activeStep.status = 'Needs QA'"
/>
<DomColorInput
v-model="accentColor"
label="Tour accent"
description="Used for selected target outlines and the preview CTA."
/>
<DomButton variant="secondary" @click="markStepReady">Mark step ready</DomButton>
</div>
</div>
</section>
</div>
<aside class="grid content-start gap-4">
<section class="rounded-lg border border-border skin-card p-4">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-muted-fg">Launch plan</p>
<h2 class="mt-1 text-lg font-semibold text-canvas-fg">{{ selectedGoal.label }} tour</h2>
<p class="mt-1 text-sm leading-6 text-muted-fg">{{ selectedGoal.description }}</p>
<div class="mt-4 grid gap-4">
<DomRadioGroup
v-model="goal"
label="Goal"
:options="goalOptions"
chrome="none"
>
<template #option="{ option }">
<span class="min-w-0">
<span class="block text-sm font-semibold text-canvas-fg">{{ option.label }}</span>
<span class="mt-0.5 block text-xs leading-5 text-muted-fg">{{ option.metric }}</span>
</span>
</template>
</DomRadioGroup>
<DomDatePicker
v-model="launchDate"
label="Launch date"
date-format="dd/mm/yyyy"
description="Schedule the first eligible audience evaluation."
/>
<DomTagCombobox
v-model="audiences"
label="Audience rules"
placeholder="Add segment..."
:options="audienceOptions"
clearable
>
<template #item="{ item }">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-canvas-fg">{{ item.label }}</p>
<p class="mt-0.5 text-xs text-muted-fg">{{ item.description }}</p>
</div>
<span class="text-xs font-semibold text-muted-fg">{{ item.count }}</span>
</div>
</template>
<template #tag="{ item, label, remove }">
<button
type="button"
class="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-1 text-xs font-medium text-canvas-fg"
@click="remove"
>
{{ item?.label || label }}
<span aria-hidden="true">x</span>
</button>
</template>
</DomTagCombobox>
</div>
<div class="mt-4 rounded-lg border border-border bg-canvas p-3">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-muted-fg">Estimated reach</p>
<p class="mt-1 text-2xl font-semibold tracking-tight text-canvas-fg">{{ Math.max(0, estimatedReach).toLocaleString() }}</p>
<p class="mt-1 text-xs leading-5 text-muted-fg">After selected inclusions and suppression rules.</p>
</div>
</section>
<LaunchChecklist :checks="checks" />
<section class="rounded-lg border border-border skin-card p-4">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-muted-fg">Payload preview</p>
<pre class="mt-3 max-h-64 overflow-auto rounded-lg bg-secondary p-3 text-xs leading-5 text-muted-fg">{{ JSON.stringify(launchPayload, null, 2) }}</pre>
</section>
</aside>
</div>
</div>
<DomDialog
v-model="publishDialogOpen"
title="Publish product tour"
description="Review the release contract before this tour is scheduled for eligible workspaces."
>
<div class="grid gap-3 text-sm">
<div class="rounded-lg border border-border bg-canvas p-3">
<p class="font-semibold text-canvas-fg">{{ completedSteps }} ready steps</p>
<p class="mt-1 text-muted-fg">All copy, targets, and placements should be approved before publishing.</p>
</div>
<div class="rounded-lg border border-border bg-canvas p-3">
<p class="font-semibold text-canvas-fg">{{ Math.max(0, estimatedReach).toLocaleString() }} estimated eligible accounts</p>
<p class="mt-1 text-muted-fg">Audience rules and suppression must be enforced by your backend.</p>
</div>
<div class="rounded-lg border border-border bg-canvas p-3">
<p class="font-semibold text-canvas-fg">Launch date {{ launchDate }}</p>
<p class="mt-1 text-muted-fg">Publishing creates a scheduled draft job, not an immediate uncontrolled release.</p>
</div>
</div>
<template #footer>
<DomButton variant="secondary" data-close>Cancel</DomButton>
<DomButton :disabled="!readyToPublish" @click="publishTour">Schedule tour</DomButton>
</template>
</DomDialog>
</section>
</template>
Integration
How to use this block
Use this block when a product team needs to author onboarding moments inside the app context, not in a disconnected form. The pattern keeps step sequence, live target preview, tooltip placement, audience targeting, schedule, and launch checks visible without falling into a dense admin console.
- Hydrate
stepsfrom a tour draft endpoint with durable target selectors, placement tokens, copy, CTA labels, and status. - Back
DomAutocompletewith searchable product surfaces, route names, data attributes, or analytics events so builders choose valid targets. - Store placement as the same token used by your popover or tour runtime. This block uses
DomPositionInputso authors can tune collision-friendly positions visually. - Keep audience rules, holdouts, suppression, and eligibility on the server. The client should preview reach and explain blockers, not become the source of truth.
- Publish tour drafts through a job with preview, scheduled, live, paused, archived, and rollback states so product teams can audit releases.
Data
Recommended product tour shape
{
id: 'tour_activation_v4',
name: 'Trial activation tour',
status: 'draft',
goal: 'activation',
accentColor: '#2563eb',
audience: {
segments: ['trial-admins', 'no-connected-source'],
holdoutPercent: 8,
suppressionRules: ['recently-dismissed-tour', 'converted-workspace']
},
schedule: {
launchDate: '2026-06-19',
timezone: 'Europe/London'
},
steps: [
{
id: 'step_connect_source',
title: 'Connect your source data',
body: 'Start with the source your team already trusts.',
cta: 'Connect source',
targetSelector: '[data-tour="connect-data"]',
targetLabel: 'Connect data tile',
placement: 'start-top',
trigger: 'onboarding-home-loaded',
status: 'Ready'
}
],
checks: [
{ id: 'selector-health', status: 'passed', detail: 'All targets exist in production routes.' },
{ id: 'audience-policy', status: 'warning', detail: 'Holdout is below recommended 10%.' }
]
}Customization
Implementation notes
Target registry
Generate autocomplete options from route metadata, instrumented data attributes, or a crawler that verifies selectors against deploy previews.
Runtime contract
Use the same placement, trigger, audience, and dismiss-state model in the builder and in-app tour runtime so previews do not drift from production behavior.
Future updates
Good follow-ups include selector health scanning, localized step variants, branching tours, analytics goal mapping, reusable tour tooltip previews, and screenshot-backed QA.