Tour

A step-by-step guide that highlights parts of the page.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Inline, Input, Tour, type TourStep } from '@hina-ui/vue'

  const open = ref(false)
  const step = ref(1)

  const steps: TourStep[] = [
    { title: 'Welcome', description: 'Take half a minute to meet the main entry points.' },
    {
      target: '#tour-hero-create',
      title: 'New project',
      description: 'Create your first project here.',
    },
    { target: '#tour-hero-search', title: 'Search', description: 'Find projects by name or tag.' },
    {
      target: '#tour-hero-share',
      title: 'Invite people',
      description: 'Send the link to colleagues and edit together.',
      side: 'left',
    },
  ]

  function start() {
    step.value = 1
    open.value = true
  }
</script>

<template>
  <Inline gap="sm" align="center" wrap>
    <Button id="tour-hero-create">New project</Button>
    <Input id="tour-hero-search" placeholder="Search projects" class="w-48" />
    <Button id="tour-hero-share" variant="outline" tone="neutral">Invite</Button>
    <Button variant="ghost" tone="neutral" @click="start">Start tour</Button>
  </Inline>
  <Tour v-model:open="open" v-model:step="step" :steps="steps" />
</template>
vue

Usage

import { Tour } from '@hina-ui/vue'
ts

steps lists the steps. Each step points at an element on the page with target and gives a title and a description. While open, a mask covers the page and leaves only the target visible, with the card next to it and an arrow pointing at it; when target is empty or no element matches, the card sits in the centre of the viewport. open and step both support two-way binding, and step starts at 1.

target can be a CSS selector, an element, a function returning a target, a { x, y, width, height } rectangle such as a DOMRect, a virtual element with getBoundingClientRect(), or an array of these; an array highlights the box around all of them. It is read when the step becomes active, so it can point at elements rendered after the tour opens. Rectangles use viewport coordinates; elements and virtual elements are followed when the page scrolls or they resize.

Examples

Blocking the target and validation

The highlighted target can be clicked and typed into by default, and focus moves between the card and the target. blockTarget blocks the target, either for the whole tour or for a single step. beforeChange runs before every step change and keeps the current step when it returns false; when it returns a Promise, the card waits for it and the Next button shows a loading indicator.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Inline, Input, Text, Tour, type TourStep } from '@hina-ui/vue'

  const open = ref(false)
  const name = ref('')
  const error = ref('')

  const steps: TourStep[] = [
    {
      target: '#tour-interactive-name',
      title: 'Name your project',
      description: 'Type a name in the field, then press Next.',
    },
    {
      target: '#tour-interactive-save',
      title: 'Save',
      description: 'The name shows up in the project list.',
      blockTarget: true,
    },
  ]

  async function beforeChange(next: number, previous: number) {
    if (previous !== 1 || next < previous) return true
    await new Promise(resolve => setTimeout(resolve, 600))
    error.value = name.value.trim() ? '' : 'A name is required'
    return !error.value
  }
</script>

<template>
  <Inline gap="sm" align="center" wrap>
    <Input id="tour-interactive-name" v-model="name" placeholder="Project name" class="w-56" />
    <Button id="tour-interactive-save">Save</Button>
    <Button variant="ghost" tone="neutral" @click="open = true">Start tour</Button>
  </Inline>
  <Tour v-model:open="open" :steps="steps" :before-change="beforeChange" @finish="error = ''">
    <template #content="{ step, current }">
      <Text tone="muted" size="sm">{{ step.description }}</Text>
      <Text v-if="error && current === 1" tone="danger" size="sm">{{ error }}</Text>
    </template>
  </Tour>
</template>
vue

Beacon

beacon makes the tour start with a pulsing dot on the top end corner of the first target and opens the card and the mask only when it is clicked; after that, Next and Back move between cards directly. beacon: true on a step also stops on the beacon when navigation reaches that step, and beacon: false hides the beacon when the step is where the tour starts. Steps without a target never show a beacon.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Inline, Tour, type TourStep } from '@hina-ui/vue'

  const open = ref(false)

  const steps: TourStep[] = [
    { target: '#tour-beacon-export', title: 'Export', description: 'You can now export to PDF.' },
    {
      target: '#tour-beacon-history',
      title: 'History',
      description: 'Every save keeps a version.',
    },
  ]
</script>

<template>
  <Inline gap="lg" align="center" wrap>
    <Button id="tour-beacon-export" variant="outline" tone="neutral">Export</Button>
    <Button id="tour-beacon-history" variant="outline" tone="neutral">History</Button>
    <Button variant="ghost" tone="neutral" @click="open = !open">
      {{ open ? 'Hide hints' : 'Show what is new' }}
    </Button>
  </Inline>
  <Tour v-model:open="open" :steps="steps" beacon />
</template>
vue

Custom content

The content slot replaces the description and the footer slot replaces the progress and the buttons; both receive the current step together with next, prev and skip. locked hides the close button, and Escape no longer skips the tour.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Indicator, Inline, Kbd, Text, Tour, type TourStep } from '@hina-ui/vue'

  const open = ref(false)

  const steps: TourStep[] = [
    {
      target: '#tour-custom-command',
      title: 'Command palette',
      description: 'Open the command palette any time.',
    },
    { target: '#tour-custom-theme', title: 'Theme', description: 'Switch between light and dark.' },
  ]
</script>

<template>
  <Inline gap="sm" align="center" wrap>
    <Button id="tour-custom-command" variant="outline" tone="neutral">Commands</Button>
    <Button id="tour-custom-theme" variant="outline" tone="neutral">Theme</Button>
    <Button variant="ghost" tone="neutral" @click="open = true">Start tour</Button>
  </Inline>
  <Tour v-model:open="open" :steps="steps" locked>
    <template #content="{ step, current }">
      <Text tone="muted" size="sm">{{ step.description }}</Text>
      <Text v-if="current === 1" size="sm" class="mt-2">
        Press
        <Kbd>⌘</Kbd>
        <Kbd>K</Kbd>
        to open it.
      </Text>
    </template>
    <template #footer="{ current, total, next, prev }">
      <Inline gap="sm" align="center" justify="between">
        <Inline gap="xs" aria-hidden="true">
          <Indicator
            v-for="index in total"
            :key="index"
            size="sm"
            :tone="index === current ? 'accent' : 'neutral'"
            :class="index === current ? undefined : 'opacity-25'"
          />
        </Inline>
        <Inline gap="sm">
          <Button v-if="current > 1" size="sm" variant="ghost" tone="neutral" @click="prev">
            Back
          </Button>
          <Button size="sm" @click="next">{{ current === total ? 'Got it' : 'Continue' }}</Button>
        </Inline>
      </Inline>
    </template>
  </Tour>
</template>
vue

Without a mask

:mask="false" removes the mask. The page stays usable during the tour and the card is no longer a modal dialog. mask="dim" keeps the mask but only darkens the page, without the blur.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Inline, Tour, type TourStep } from '@hina-ui/vue'

  const open = ref(false)

  const steps: TourStep[] = [
    {
      target: '#tour-plain-filter',
      title: 'Filter',
      description: 'The page stays usable during the tour.',
    },
    {
      target: '#tour-plain-sort',
      title: 'Sort',
      description: 'Click to switch between ascending and descending.',
    },
  ]
</script>

<template>
  <Inline gap="sm" align="center" wrap>
    <Button id="tour-plain-filter" variant="outline" tone="neutral">Filter</Button>
    <Button id="tour-plain-sort" variant="outline" tone="neutral">Sort</Button>
    <Button variant="ghost" tone="neutral" @click="open = true">Start tour</Button>
  </Inline>
  <Tour v-model:open="open" :steps="steps" :mask="false" />
</template>
vue

Scripted steps

A step’s play runs when the step becomes active and can be async. It demonstrates the real interface: opening panels, moving the highlight and driving a virtual pointer. It receives a context with:

  • signal, aborted when the step is left or the tour closes.
  • defer(cleanup), which registers a cleanup. Leaving the step, closing the tour or replaying first aborts signal, then runs the cleanups in reverse order.
  • spotlight(target?, options?), which moves the highlight to another target within the step, with the card following it. Without target it returns to the step’s target; null closes the highlight and centres the card. options takes padding, radius and wait. The Promise settles once the highlight comes to rest.
  • pointer, the virtual pointer described below.
  • waitFor(target, timeout?), which waits for a target to appear and resolves whether it did, waiting 5000ms at most by default.
  • sleep(ms), which waits.

Once signal is aborted, sleep, spotlight, waitFor and the pointer methods reject with an AbortError, which stops the script; the tour ignores that error.

A step with play shows a Replay button at the bottom of the card. It does the same as replay(): runs the cleanups, waits for the animations they start to finish, moves the highlight back to the step’s target and runs the script again.

blockPage makes the whole page, including the highlight, ignore clicks and input during the step while the card stays usable. It can be set on the tour or on a single step.

Page 12

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Drawer, Inline, List, ListItem, Text, Tour, type TourStep } from '@hina-ui/vue'

  const open = ref(false)
  const step = ref(1)
  const contents = ref(false)
  const page = ref(12)

  const steps: TourStep[] = [
    {
      target: '#tour-play-contents',
      title: 'Contents',
      description: 'Open the contents here.',
      play: async ({ pointer }) => {
        await pointer.move('#tour-play-contents')
        await pointer.tap()
      },
    },
    {
      target: '#tour-play-chapter',
      title: 'Jump to a chapter',
      description: 'Pick a chapter in the contents to go there.',
      wait: 2000,
      play: async ({ pointer, defer, waitFor }) => {
        contents.value = true
        defer(() => (contents.value = false))
        await waitFor('#tour-play-chapter')
        await pointer.move('#tour-play-chapter')
        await pointer.tap()
      },
    },
    {
      target: '#tour-play-page',
      title: 'Turn pages',
      description: 'Press ← and → to turn pages.',
      play: async ({ pointer, sleep }) => {
        await pointer.key('→')
        page.value += 1
        await sleep(400)
        await pointer.key('←')
        page.value -= 1
      },
    },
  ]

  function start() {
    step.value = 1
    open.value = true
  }
</script>

<template>
  <Inline gap="sm" align="center" wrap>
    <Button id="tour-play-contents" variant="outline" tone="neutral" @click="contents = true">
      Contents
    </Button>
    <Text id="tour-play-page" tone="muted" class="tabular-nums">Page {{ page }}</Text>
    <Button variant="ghost" tone="neutral" @click="start">Show me how</Button>
  </Inline>
  <Drawer v-model:open="contents" title="Contents">
    <template #content>
      <List>
        <ListItem>Chapter 1 · Equinox</ListItem>
        <ListItem>Chapter 2 · Distant thunder</ListItem>
        <ListItem id="tour-play-chapter">Chapter 3 · Night rain</ListItem>
      </List>
    </template>
  </Drawer>
  <Tour v-model:open="open" v-model:step="step" :steps="steps" mask="dim" block-page />
</template>
vue

Virtual pointer

pointer draws a pointer above the tour that never catches clicks. It only shows actions and dispatches no events; the script performs the real action itself, for example by changing state or calling click().

Method
Description
move(target, duration?)
Moves to the centre of a target or to { x, y }, 600ms by default; a hidden pointer fades in from just below and to the right
tap()
Presses and releases with a ripple
press(duration?)
Long press, 800ms by default, with a growing circle
drag(target, duration?, onMove?)
Holds and drags to a target, 800ms by default; onMove receives the pointer position every frame to dispatch real events from
key(key, duration?)
Shows a key, 700ms by default, next to the pointer when it is visible and at the bottom of the viewport otherwise
hide()
Hides the pointer

Pointer methods start once the step’s highlight has come to rest and its card is shown. Moves and drags read their end point again every frame, so the pointer keeps up with a moving target. A method rejects with an error when its target does not exist; wait for a target that appears after the interface updates with waitFor() first.

The pointer follows (pointer: coarse) by default: a mouse arrow for fine pointers and a fingertip dot for touch screens. The pointer prop can fix it to 'mouse' or 'touch'. With reduced motion, moves and drags jump to the end and onMove receives only the end position. The pointer hides when the step is left.

Waiting for a target

wait is the longest time in milliseconds to wait for the target to appear. When the target does not exist yet as the step becomes active, for example until the script opens a panel, the card hides and the highlight stays where the previous step left it; once the target appears, the card shows up at its new place. If it still does not exist after the wait, the card is centred.

With modal overlays

The tour sits above Dialog, Drawer, Sheet, menus and other overlays, at --hn-z-overlay plus 1. A modal overlay opened by a script treats the tour as its own: clicking the card or the mask does not close it, the card stays visible to screen readers and focus stays on the card. Other layers you draw yourself get the same treatment through OverlayBranch.

Behavior

  • Opening moves focus to the card; closing returns it to where it was before the tour opened.
  • With a mask the card is modal: the rest of the page is hidden from screen readers and Tab cycles inside the card; the target joins the cycle unless blockTarget blocks it.
  • Left and right arrows move to the previous and next step, swapped in RTL. Escape skips the tour and emits skip and close. The keys work anywhere while the tour is open, without focus on the card; arrows in text fields are left alone. In a step without play or blockPage, an overlay opened from the target handles the keys first. Clicking the mask does not close the tour.
  • The button of the last step reads Done; clicking it emits finish and closes the tour.
  • When a step becomes active, a target outside the viewport is scrolled to the centre; scroll: false turns this off. The mask and the card follow the target when it moves, resizes or the page scrolls.
  • Between steps the card fades out where it is and keeps the previous step’s content. When the new target is still animating, the highlight waits for it to stop, then springs to it; once the highlight rests, the card appears at its new place. When spotlight() moves the highlight within a step, the card follows it.
  • When the target element is removed, the highlight stays where it is; once a target given as a selector or a function matches again, the highlight moves to the new element.
  • The highlight opens from the centre of its target and closes in place onto the centre of its target before a step without a target, taking 300ms each.
  • When the card does not fit on its side of the target it flips to the other side, and when it still does not fit it shifts into the viewport, over the target if needed.
  • Page scrolling stays enabled, and the tour renders nothing on the server.

Accessibility

  • The card is role="dialog", with aria-modal when a mask is shown. Its name comes from the step title and the description is linked through aria-describedby.
  • The card carries aria-busy while beforeChange is pending.
  • The beacon is a button whose name contains the step number and title.
  • The virtual pointer and the key hint are hidden from screen readers.
  • Button texts, the progress and the beacon name come from the current locale and can be replaced.

API

Props

Prop
Type
Default
Description
steps
TourStep[]
required
The steps
v-model:open
boolean
false
Whether the tour is open
v-model:step
number
1
The current step, starting at 1
mask
boolean | 'dim'
true
Shows the mask and makes the card modal; 'dim' darkens without blur
locked
boolean
false
Hides the close button; Escape does not skip
beacon
boolean
false
Starts the tour from a beacon
blockTarget
boolean
false
Blocks the target of every step
blockPage
boolean
false
Makes the page except the card ignore clicks and input
pointer
'auto' | 'mouse' | 'touch'
'auto'
Look of the virtual pointer
beforeChange
TourBeforeChange
—
Runs before a step change; false keeps the step
nextText
string
current locale
Text of the Next button
prevText
string
current locale
Text of the Back button
finishText
string
current locale
Text of the button on the last step
skipText
string
current locale
Name of the close button
replayText
string
current locale
Name of the Replay button
dir
'ltr' | 'rtl'
inherited
Reading direction
class
string
—
Classes added to the card

TourBeforeChange is (step: number, previousStep: number) => boolean | void | Promise<boolean | void>.

TourStep

Field
Type
Default
Description
target
TourTarget
—
The highlighted target, see Usage; empty centres the card
title
string
—
Title
description
string
—
Description
side
'top' | 'right' | 'bottom' | 'left'
'bottom'
Side of the target the card sits on
align
'start' | 'center' | 'end'
'center'
Alignment of the card against the target
padding
number
8
Space around the target in the highlight, px
radius
number
8
Corner radius of the highlight, px
scroll
boolean
true
Scrolls a target outside the viewport into view
blockTarget
boolean
inherited
Blocks the target of this step
blockPage
boolean
inherited
Blocks the whole page during this step
wait
number
0
Longest wait for the target, ms
play
(context: TourPlayContext) => void | Promise<void>
—
Script run when the step becomes active
beacon
boolean
—
See Beacon

Slots

Slot
Props
Description
content
TourSlotProps
Replaces the description
footer
TourSlotProps
Replaces the progress and the buttons

TourSlotProps is { step, current, total, busy, playing, next, prev, skip, replay }; playing is true while the script runs.

Events

Event
Payload
Description
update:open
open: boolean
Opens or closes the tour
update:step
step: number
Changes the step
finish
—
Done was clicked on the last step
skip
—
The close button or Escape was used
stepEnter
step: number
A step became active, before its script starts
stepLeave
step: number
A step was left, after its cleanups ran
close
reason: TourCloseReason
The tour closed; the reason is 'finish', 'skip', 'escape' or 'close' (close() or closed from outside)

Expose

next(), prev(), goTo(step), close() and replay() are available through a template ref. The first three go through beforeChange; close() closes the tour without emitting finish or skip; replay() runs the current step’s script again.