Components
Onboarding Tour
A spotlight product tour anchored to CSS selectors. Purely presentational — no Morphos equivalent.
Installation
npx kosmesis add onboarding-tourpnpm dlx kosmesis add onboarding-touryarn dlx kosmesis add onboarding-tourbunx kosmesis add onboarding-tourCopy and paste the following code into your project.
import { StatefulComponent } from "@praxisjs/core";import { Component, FunctionProp, Prop, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import { cn } from "@/lib/utils";export interface TourStep { target: string; title: string; description: string;}export interface OnboardingTourProps { steps: TourStep[]; open?: boolean; onOpenChange?: (open: boolean) => void; class?: string; id?: string;}// The spotlight "hole" is a `box-shadow: 0 0 0 9999px` trick, no SVG mask needed. The whole// conditional tree lives inside one child thunk since `render()` itself only runs once per mount.@Component()export class OnboardingTour extends StatefulComponent { @Prop() steps: TourStep[] = []; @Prop() open = true; @Prop() class?: string; @Prop() id?: string; @FunctionProp() onOpenChange?: OnboardingTourProps["onOpenChange"]; @Ref<HTMLDivElement>() rootRef!: RefType<HTMLDivElement>; @State() _index = 0; @State() _rect: DOMRect | undefined = undefined; @State() _open = true; private readonly _updateRect = () => { const step = this.steps[this._index] as TourStep | undefined; const target = step ? document.querySelector(step.target) : null; this._rect = target?.getBoundingClientRect(); }; onBeforeMount(): void { this._open = this.open; } onMount(): void { this._updateRect(); window.addEventListener("resize", this._updateRect); window.addEventListener("scroll", this._updateRect, true); } onUnmount(): void { window.removeEventListener("resize", this._updateRect); window.removeEventListener("scroll", this._updateRect, true); } get isLast(): boolean { return this._index >= this.steps.length - 1; } next(): void { if (this.isLast) { this.close(); return; } this._index += 1; this._updateRect(); } prev(): void { if (this._index === 0) return; this._index -= 1; this._updateRect(); } close(): void { this._open = false; this.onOpenChange?.(false); } render() { return ( <div ref={this.rootRef} id={this.id} data-slot="onboarding-tour" class={cn("pointer-events-none fixed inset-0 z-100", this.class)} > {() => { if (!this._open) return null; const step = this.steps[this._index] as TourStep | undefined; const rect = this._rect; if (!step || !rect) return null; return ( <div class="contents"> <div class="pointer-events-auto absolute rounded-md transition-all duration-200" style={{ top: `${String(rect.top - 4)}px`, left: `${String(rect.left - 4)}px`, width: `${String(rect.width + 8)}px`, height: `${String(rect.height + 8)}px`, boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.6)", }} /> <div role="dialog" aria-modal class="pointer-events-auto absolute w-72 rounded-lg border bg-popover p-4 text-popover-foreground shadow-lg transition-all duration-200" style={{ top: `${String(rect.bottom + 12)}px`, left: `${String(rect.left)}px` }} > <p class="text-sm font-semibold">{step.title}</p> <p class="mt-1 text-sm text-muted-foreground">{step.description}</p> <div class="mt-4 flex items-center justify-between"> <button type="button" class="text-xs text-muted-foreground hover:text-foreground" onClick={() => { this.close(); }} > Skip </button> <div class="flex gap-2"> {this._index > 0 && ( <button type="button" class="rounded-md border px-2.5 py-1 text-xs hover:bg-accent hover:text-accent-foreground" onClick={() => { this.prev(); }} > Back </button> )} <button type="button" class="rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90" onClick={() => { this.next(); }} > {this.isLast ? "Finish" : "Next"} </button> </div> </div> </div> </div> ); }} </div> ); }}Install the following dependencies:
npm install @praxisjs/csspnpm add @praxisjs/cssyarn add @praxisjs/cssbun add @praxisjs/cssCopy and paste the following code into your project.
import { StatefulComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component, FunctionProp, Prop, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);class OnboardingTourStyles extends Stylesheet { $root = this.css({ pointerEvents: "none", position: "fixed", inset: "0", zIndex: 100 }); $contents = this.css({ display: "contents" }); $spotlight = this.css({ pointerEvents: "auto", position: "absolute", borderRadius: "0.375rem", transition: "all 200ms ease", }); $card = this.css({ pointerEvents: "auto", position: "absolute", width: "18rem", borderRadius: "0.5rem", border: `1px solid ${t.border}`, backgroundColor: t.popover, color: t.popoverForeground, padding: "1rem", boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", transition: "all 200ms ease", }); $title = this.css({ fontSize: "0.875rem", fontWeight: 600 }); $description = this.css({ marginTop: "0.25rem", fontSize: "0.875rem", color: t.mutedForeground }); $footer = this.css({ marginTop: "1rem", display: "flex", alignItems: "center", justifyContent: "space-between" }); $skip = this.css({ fontSize: "0.75rem", color: t.mutedForeground, cursor: "pointer" }).on("&:hover", { color: t.foreground, }); $actions = this.css({ display: "flex", gap: "0.5rem" }); $back = this.css({ borderRadius: "0.375rem", border: `1px solid ${t.border}`, padding: "0.25rem 0.625rem", fontSize: "0.75rem", cursor: "pointer", }).on("&:hover", { backgroundColor: t.accent, color: t.accentForeground }); $next = this.css({ borderRadius: "0.375rem", backgroundColor: t.primary, padding: "0.25rem 0.625rem", fontSize: "0.75rem", fontWeight: 500, color: t.primaryForeground, cursor: "pointer", }).on("&:hover", { backgroundColor: `color-mix(in oklab, ${t.primary} 90%, transparent)` });}export interface TourStep { target: string; title: string; description: string;}export interface OnboardingTourProps { steps: TourStep[]; open?: boolean; onOpenChange?: (open: boolean) => void; class?: string; id?: string;}// The spotlight "hole" is a `box-shadow: 0 0 0 9999px` trick, no SVG mask needed. The whole// conditional tree lives inside one child thunk since `render()` itself only runs once per mount.@Component()export class OnboardingTour extends StatefulComponent { @Styled(OnboardingTourStyles) $s!: OnboardingTourStyles; @Prop() steps: TourStep[] = []; @Prop() open = true; @Prop() class?: string; @Prop() id?: string; @FunctionProp() onOpenChange?: OnboardingTourProps["onOpenChange"]; @Ref<HTMLDivElement>() rootRef!: RefType<HTMLDivElement>; @State() _index = 0; @State() _rect: DOMRect | undefined = undefined; @State() _open = true; private readonly _updateRect = () => { const step = this.steps[this._index] as TourStep | undefined; const target = step ? document.querySelector(step.target) : null; this._rect = target?.getBoundingClientRect(); }; onBeforeMount(): void { this._open = this.open; } onMount(): void { this._updateRect(); window.addEventListener("resize", this._updateRect); window.addEventListener("scroll", this._updateRect, true); } onUnmount(): void { window.removeEventListener("resize", this._updateRect); window.removeEventListener("scroll", this._updateRect, true); } get isLast(): boolean { return this._index >= this.steps.length - 1; } next(): void { if (this.isLast) { this.close(); return; } this._index += 1; this._updateRect(); } prev(): void { if (this._index === 0) return; this._index -= 1; this._updateRect(); } close(): void { this._open = false; this.onOpenChange?.(false); } render() { return ( <div ref={this.rootRef} id={this.id} data-slot="onboarding-tour" class={cx(this.$s.$root, this.class)}> {() => { if (!this._open) return null; const step = this.steps[this._index] as TourStep | undefined; const rect = this._rect; if (!step || !rect) return null; return ( <div class={this.$s.$contents}> <div class={this.$s.$spotlight} style={{ top: `${String(rect.top - 4)}px`, left: `${String(rect.left - 4)}px`, width: `${String(rect.width + 8)}px`, height: `${String(rect.height + 8)}px`, boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.6)", }} /> <div role="dialog" aria-modal class={this.$s.$card} style={{ top: `${String(rect.bottom + 12)}px`, left: `${String(rect.left)}px` }} > <p class={this.$s.$title}>{step.title}</p> <p class={this.$s.$description}>{step.description}</p> <div class={this.$s.$footer}> <button type="button" class={this.$s.$skip} onClick={() => { this.close(); }}> Skip </button> <div class={this.$s.$actions}> {this._index > 0 && ( <button type="button" class={this.$s.$back} onClick={() => { this.prev(); }}> Back </button> )} <button type="button" class={this.$s.$next} onClick={() => { this.next(); }}> {this.isLast ? "Finish" : "Next"} </button> </div> </div> </div> </div> ); }} </div> ); }}Examples
About
Purely presentational, no Morphos equivalent. The "hole" is a box-shadow: 0 0 0 9999px trick (no
SVG mask needed) sized to the current step's target's getBoundingClientRect(), recomputed on
mount, on step change, and on resize/scroll. target is a plain CSS selector resolved with
document.querySelector — the element must already exist in the DOM when the step becomes active.
Usage
import { OnboardingTour } from "@/components/ui/onboarding-tour";
<OnboardingTour
open={open}
onOpenChange={setOpen}
steps={[
{ target: "#new-project", title: "Start a project", description: "Click here to create your first project." },
{ target: "#settings", title: "Configure it", description: "Tune preferences for your workspace." },
]}
/>Props
| Prop | Type | Default |
|---|---|---|
steps | { target: string; title: string; description: string }[] | — |
open | boolean | true |
onOpenChange | (open: boolean) => void | — |
class | string | — |