Skip to content
ShineUI
Install

Tour

A guided first walk.

<sui-tour>OverlaysStable

About

Guided onboarding walk: a dimmed stage, an orange ring around each target and a step card with Back, Skip and Next. Start it from a button; it ends itself. Reach for it on first use of a screen, and keep it to a handful of steps; use sui-stepper for a process the user works through.

Usage

<sui-tour></sui-tour> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-tour"); el.steps = [ { heading: "Bank feed lands here", body: "Overnight transactions wait for review.", target: "#tour-basic-feed" }, { heading: "Watch the aging", body: "Overdue balances roll up here.", target: "#tour-basic-aging" }, { heading: "One accent action", body: "The orange button is the main move.", target: "#tour-basic-post" }, ]; /* SuiTourStep[] */ el.addEventListener("suiClose", (e) => console.log(e.detail)); </script>
import { SuiTour } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { const steps = [ { heading: "Bank feed lands here", body: "Overnight transactions wait for review.", target: "#tour-basic-feed" }, { heading: "Watch the aging", body: "Overdue balances roll up here.", target: "#tour-basic-aging" }, { heading: "One accent action", body: "The orange button is the main move.", target: "#tour-basic-post" }, ]; /* SuiTourStep[] */ return <SuiTour steps={steps} onSuiClose={(e) => console.log(e.detail)} />; }
<script setup> import { SuiTour } from "@shineui/vue"; import "@shineui/core/shineui.css"; const steps = [ { heading: "Bank feed lands here", body: "Overnight transactions wait for review.", target: "#tour-basic-feed" }, { heading: "Watch the aging", body: "Overdue balances roll up here.", target: "#tour-basic-aging" }, { heading: "One accent action", body: "The orange button is the main move.", target: "#tour-basic-post" }, ]; // SuiTourStep[] const onChange = (e) => console.log(e.detail); </script> <template> <SuiTour :steps="steps" @sui-close="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiTour } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiTour], template: `<sui-tour [(open)]="open" [steps]="steps" (suiClose)="onChange($event)"></sui-tour>`, }) export class Example { open = false; steps = [ { heading: "Bank feed lands here", body: "Overnight transactions wait for review.", target: "#tour-basic-feed" }, { heading: "Watch the aging", body: "Overdue balances roll up here.", target: "#tour-basic-aging" }, { heading: "One accent action", body: "The orange button is the main move.", target: "#tour-basic-post" }, ]; // SuiTourStep[] onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

The card is a modal dialog labeled Product tour; focus is trapped on it and returns to it on every step, so the new heading and body are read. The stage takes every pointer event, including over the ring, so a step cannot ask the user to click the thing it points at.

Bank feed
18 transactions to review.
Aging
$12,480.00 overdue.
<div style="display:grid;gap:12px;max-width:340px"> <sui-button id="tour-basic-start" variant="outline" label="Start tour" style="justify-self:start"></sui-button> <div id="tour-basic-feed" style="padding:12px 14px;border:1px solid var(--border);border-radius:var(--radius-lg);font:460 13px/1.5 var(--font-sans)"> <b>Bank feed</b><br><span style="color:var(--ink-muted)">18 transactions to review.</span> </div> <div id="tour-basic-aging" style="padding:12px 14px;border:1px solid var(--border);border-radius:var(--radius-lg);font:460 13px/1.5 var(--font-sans)"> <b>Aging</b><br><span style="color:var(--ink-muted)">$12,480.00 overdue.</span> </div> <sui-button id="tour-basic-post" variant="accent" label="Post entry" style="justify-self:start"></sui-button> </div> <sui-tour id="tour-basic"></sui-tour>
// setup(root): properties and events setup(root) { const tour = root.querySelector('#tour-basic'); tour.steps = walkthrough('tour-basic'); root.querySelector('#tour-basic-start').addEventListener('click', () => { tour.step = 0; tour.open = true; }); }

Targets in a scrolling panel

Each step names a CSS selector; the tour measures the live element, scrolls it into view on arrival and re-measures on every scroll and resize, so the ring stays on it. The card sits below the ring and flips above it when there is no room.

INV-1041 Silver Hills Church$2,400.00
INV-1042 Northgate Dental$980.00
INV-1043 Riverbend Group$6,420.00
INV-1044 Cedar Park Clinic$1,150.00
INV-1045 Halyard Freight$18,300.00
<div style="display:grid;gap:12px;max-width:340px"> <sui-button id="tour-scroll-start" variant="outline" label="Walk the ledger"></sui-button> <div id="tour-scroll-box" tabindex="0" role="group" aria-label="Steps below the fold" style="height:150px;overflow:auto;display:grid;gap:8px;align-content:start;padding:10px;border:1px solid var(--border);border-radius:var(--radius-lg)"> <div id="tour-scroll-first" style="display:flex;justify-content:space-between;gap:12px;padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius-md);font:460 12.5px/1.4 var(--font-sans)"> <span>INV-1041 Silver Hills Church</span><span style="color:var(--ink-muted)">$2,400.00</span> </div> <div style="display:flex;justify-content:space-between;gap:12px;padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius-md);font:460 12.5px/1.4 var(--font-sans)"> <span>INV-1042 Northgate Dental</span><span style="color:var(--ink-muted)">$980.00</span> </div> <div style="display:flex;justify-content:space-between;gap:12px;padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius-md);font:460 12.5px/1.4 var(--font-sans)"> <span>INV-1043 Riverbend Group</span><span style="color:var(--ink-muted)">$6,420.00</span> </div> <div style="display:flex;justify-content:space-between;gap:12px;padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius-md);font:460 12.5px/1.4 var(--font-sans)"> <span>INV-1044 Cedar Park Clinic</span><span style="color:var(--ink-muted)">$1,150.00</span> </div> <div id="tour-scroll-late" style="display:flex;justify-content:space-between;gap:12px;padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius-md);font:460 12.5px/1.4 var(--font-sans)"> <span>INV-1045 Halyard Freight</span><span style="color:var(--ink-muted)">$18,300.00</span> </div> </div> </div> <sui-tour id="tour-scroll"></sui-tour>
// setup(root): properties and events setup(root) { const tour = root.querySelector('#tour-scroll'); tour.steps = [ { heading: 'The oldest balance first', body: 'The ledger opens on the invoice that has waited longest.', target: '#tour-scroll-first' }, { heading: 'A row below the fold', body: 'This step scrolls the panel to reach its target.', target: '#tour-scroll-late' }, { heading: 'The whole ledger', body: 'A step can ring a container as well as a control.', target: '#tour-scroll-box' }, ]; root.querySelector('#tour-scroll-start').addEventListener('click', () => { tour.step = 0; tour.open = true; }); }

Centered welcome and wrap-up steps

A step with no target centers its card, which is what a welcome or a wrap-up wants. Back is disabled on the first step and Next reads Finish on the last.

Bank feed
18 transactions to review.
Aging
$12,480.00 overdue.
<div style="display:grid;gap:12px;max-width:340px"> <sui-button id="tour-centered-start" variant="outline" label="Start with a welcome" style="justify-self:start"></sui-button> <div id="tour-centered-feed" style="padding:12px 14px;border:1px solid var(--border);border-radius:var(--radius-lg);font:460 13px/1.5 var(--font-sans)"> <b>Bank feed</b><br><span style="color:var(--ink-muted)">18 transactions to review.</span> </div> <div id="tour-centered-aging" style="padding:12px 14px;border:1px solid var(--border);border-radius:var(--radius-lg);font:460 13px/1.5 var(--font-sans)"> <b>Aging</b><br><span style="color:var(--ink-muted)">$12,480.00 overdue.</span> </div> <sui-button id="tour-centered-post" variant="accent" label="Post entry" style="justify-self:start"></sui-button> </div> <sui-tour id="tour-centered"></sui-tour>
// setup(root): properties and events setup(root) { const tour = root.querySelector('#tour-centered'); tour.steps = [ { heading: 'Welcome to the ledger', body: 'Four screens run the month end. Here are two of them.' }, ...walkthrough('tour-centered').slice(0, 2), { heading: 'That is the walk', body: 'Reopen it from Help whenever you need it.' }, ]; root.querySelector('#tour-centered-start').addEventListener('click', () => { tour.step = 0; tour.open = true; }); }

A fixed spotlight rectangle

Steps that name no selector fall back to the tour target rectangle, which is measured against the viewport rather than the page, so it suits chrome that is itself fixed, such as a top bar. Name a selector per step for anything that sits in the page.

<sui-button id="tour-rect-start" variant="outline" label="Spotlight the top bar"></sui-button> <sui-tour id="tour-rect"></sui-tour>
// setup(root): properties and events setup(root) { const tour = root.querySelector('#tour-rect'); tour.steps = [{ heading: 'The workspace switcher', body: 'Every company you keep books for lives up here.' }]; tour.target = { top: '84px', left: '24px', width: '240px', height: '52px' }; root.querySelector('#tour-rect-start').addEventListener('click', () => { tour.step = 0; tour.open = true; }); }

Step changes and close reasons

suiStepChange fires after Next or Back and carries the new index, and suiClose fires before the tour ends and says why: pick for Finish, cancel for Skip, close-button for the X, escape for Escape. Writing step or open from outside moves the tour and emits neither.

Bank feed
18 transactions to review.
Aging
$12,480.00 overdue.
Not started.
<div style="display:grid;gap:12px;max-width:340px"> <sui-button id="tour-events-start" variant="outline" label="Start tour" style="justify-self:start"></sui-button> <div id="tour-events-feed" style="padding:12px 14px;border:1px solid var(--border);border-radius:var(--radius-lg);font:460 13px/1.5 var(--font-sans)"> <b>Bank feed</b><br><span style="color:var(--ink-muted)">18 transactions to review.</span> </div> <div id="tour-events-aging" style="padding:12px 14px;border:1px solid var(--border);border-radius:var(--radius-lg);font:460 13px/1.5 var(--font-sans)"> <b>Aging</b><br><span style="color:var(--ink-muted)">$12,480.00 overdue.</span> </div> <sui-button id="tour-events-post" variant="accent" label="Post entry" style="justify-self:start"></sui-button> <sui-button id="tour-events-resume" variant="outline" size="sm" label="Resume where it stopped"></sui-button> <div id="tour-events-log" style="font:460 12.5px/1.6 var(--font-sans);color:var(--ink-muted)">Not started.</div> </div> <sui-tour id="tour-events"></sui-tour>
// setup(root): properties and events setup(root) { const tour = root.querySelector('#tour-events'); const log = root.querySelector('#tour-events-log'); const said = { pick: 'Finished.', cancel: 'Skipped.', 'close-button': 'Closed with the X.', escape: 'Ended with Escape.' }; let seen = 0; tour.steps = walkthrough('tour-events'); root.querySelector('#tour-events-start').addEventListener('click', () => { seen = 0; tour.step = 0; tour.open = true; }); root.querySelector('#tour-events-resume').addEventListener('click', () => { tour.step = seen; tour.open = true; log.textContent = 'Resumed at step ' + (seen + 1) + '. An outside write emits no suiStepChange.'; }); tour.addEventListener('suiStepChange', (e) => { seen = e.detail; log.textContent = 'suiStepChange: step ' + (e.detail + 1) + ' of ' + tour.steps.length + '.'; }); tour.addEventListener('suiClose', (e) => { log.textContent = 'suiClose: ' + e.detail + '. ' + (said[e.detail] || ''); }); }

Refusing a skip

suiClose is cancelable, so this setup walk refuses Skip and stays open. Escape and the X still end it, so nobody is held in the tour.

Bank feed
18 transactions to review.
Aging
$12,480.00 overdue.
No close requested yet.
<div style="display:grid;gap:12px;max-width:340px"> <sui-button id="tour-veto-start" variant="outline" label="Start required setup" style="justify-self:start"></sui-button> <div id="tour-veto-feed" style="padding:12px 14px;border:1px solid var(--border);border-radius:var(--radius-lg);font:460 13px/1.5 var(--font-sans)"> <b>Bank feed</b><br><span style="color:var(--ink-muted)">18 transactions to review.</span> </div> <div id="tour-veto-aging" style="padding:12px 14px;border:1px solid var(--border);border-radius:var(--radius-lg);font:460 13px/1.5 var(--font-sans)"> <b>Aging</b><br><span style="color:var(--ink-muted)">$12,480.00 overdue.</span> </div> <sui-button id="tour-veto-post" variant="accent" label="Post entry" style="justify-self:start"></sui-button> <div id="tour-veto-log" style="font:460 12.5px/1.6 var(--font-sans);color:var(--ink-muted)">No close requested yet.</div> </div> <sui-tour id="tour-veto"></sui-tour>
// setup(root): properties and events setup(root) { const tour = root.querySelector('#tour-veto'); const log = root.querySelector('#tour-veto-log'); tour.steps = walkthrough('tour-veto'); root.querySelector('#tour-veto-start').addEventListener('click', () => { tour.step = 0; tour.open = true; }); tour.addEventListener('suiClose', (e) => { if (e.detail === 'cancel') { e.preventDefault(); log.textContent = 'Skip refused. Payroll setup has to be finished.'; return; } log.textContent = 'Ended by ' + e.detail + '.'; }); }

Keyboard

KeyAction
Tab Shift+TabCycles through the X, Back, Skip and Next on the card and never leaves it.
Enter SpacePresses the focused button. Next moves a step and returns focus to the card; Finish ends the tour with reason pick.
EscapeEnds the tour with reason escape, unless the listener refuses. Only the topmost open modal answers it.

API

<sui-tour>

Properties

PropertyAttributeTypeDefaultDescription
openopenbooleanfalseShows the tour. Mutable; the element closes itself on Skip, Finish, the X or Escape unless the suiClose listener calls preventDefault(). Not in the reference, which mounts and unmounts the tour; added so the element follows every other overlay here. The element never opens itself, so there is no suiOpen; watch open. The stage paints in the browser's top layer while it shows, so a transformed, contained or clipping ancestor cannot move the ring off its target or cut the card off; the rest of the page is inert and does not scroll until the tour closes, and a node the page adds while it shows goes inert too. The card is role="dialog" with no aria-modal, so VoiceOver keeps reading the toast region; Tab goes on from the card into the toast stack and back. An element the page marks data-sui-keep-live stays usable with everything in it, and focus may move into it and back.
stepstepnumber0Current step index (the reference defaultStep). Mutable; Next and Back update it, and a value outside the steps is clamped to the nearest end, so a stale index shows the last step rather than nothing.
stepsproperty onlySuiTourStep[][]Steps: { heading, body, target? }[]. Property, not attribute. A step with a target selector is measured live and followed on scroll and resize; a target outside the viewport is scrolled into view first, smoothly unless the reader asked for less motion, and the stage shows once the page has settled. The card sits below the ring and flips above it when there is no room, and it glides between two targets that are both in view. A step with no selector, and no whole-tour target, centers its card.
targetproperty onlySuiTourTargetSuiTourTarget | undefinedFallback spotlight rect { top, left, width, height } as CSS lengths, for every step that names no target selector of its own. Property, not attribute. Omit to center the card.

Events

EventDetailReactVueDescription
suiCloseSuiCloseReason"cancel" | "close-button" | "escape" | "outside" | "pick" | "scrim" | "trigger"onSuiClose@sui-closeFires before the tour ends itself. Detail is why: 'pick' for Finish (the tour was completed), 'cancel' for Skip, 'close-button' for the X, 'escape' for Escape. Cancelable: preventDefault() keeps the tour open. It does not bubble.
suiStepChangenumberonSuiStepChange@sui-step-changeFires after Next or Back; detail is the new step index. Does not bubble.

Types

SuiCloseReason typeWhy a dismissible surface is closing. Detail of the cancelable suiClose. A consumer expresses "a stray backdrop click must not close this" by calling preventDefault() on the reason it does not accept, which is why none of these needs a prop of its own.
No members.
SuiTourStep interfaceOne tour step. heading is the reference title (renamed per the port conventions). target is a CSS selector for the element this step spotlights; the rectangle is measured from the live element and followed on scroll and resize, so the host does not measure anything.
headingstring
bodystring
target?string
SuiTourTarget interfaceSpotlight rectangle as CSS lengths, relative to the viewport.
top?string
left?string
width?string
height?string

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

See also