Kosmesis
Components

Onboarding Tour

A spotlight product tour anchored to CSS selectors. Purely presentational — no Morphos equivalent.

Installation

npx kosmesis add onboarding-tour
pnpm dlx kosmesis add onboarding-tour
yarn dlx kosmesis add onboarding-tour
bunx kosmesis add onboarding-tour

Copy and paste the following code into your project.

onboarding-tour.tsx
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/css
pnpm add @praxisjs/css
yarn add @praxisjs/css
bun add @praxisjs/css

Copy and paste the following code into your project.

onboarding-tour.tsx
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

PropTypeDefault
steps{ target: string; title: string; description: string }[]
openbooleantrue
onOpenChange(open: boolean) => void
classstring

On this page