diff --git a/.changeset/initial-focus-skip.md b/.changeset/initial-focus-skip.md
new file mode 100644
index 0000000000..4b994336c8
--- /dev/null
+++ b/.changeset/initial-focus-skip.md
@@ -0,0 +1,25 @@
+---
+"@zag-js/dom-query": minor
+"@zag-js/dialog": patch
+"@zag-js/drawer": patch
+---
+
+Improve initial focus selection in dialogs and drawers. Mark chrome controls with `data-no-autofocus` to skip them, or mark the desired target with `data-autofocus`.
+
+```jsx
+
+ {/* skipped on open, still in tab order */}
+
+ Close
+
+
+ ?
+
+
+ {/* receives initial focus */}
+
+ Save
+
+```
+
+Priority: `initialFocusEl` → `[data-autofocus]` → first tabbable without `[data-no-autofocus]` → content root.
diff --git a/.changeset/popper-apply-styles.md b/.changeset/popper-apply-styles.md
new file mode 100644
index 0000000000..a939166cc7
--- /dev/null
+++ b/.changeset/popper-apply-styles.md
@@ -0,0 +1,5 @@
+---
+"@zag-js/popper": patch
+---
+
+- Added an `applyStyles` option to control whether computed position styles are written directly to the DOM.
diff --git a/.changeset/tour-dismiss-fixes.md b/.changeset/tour-dismiss-fixes.md
new file mode 100644
index 0000000000..7297a69dff
--- /dev/null
+++ b/.changeset/tour-dismiss-fixes.md
@@ -0,0 +1,6 @@
+---
+"@zag-js/tour": patch
+---
+
+- Fixed issue where dismissing a tour from a step's `effect` skipped cleanup and could miss firing the "completed" status.
+- Fixed issue where a tooltip step's position could reset unexpectedly when the tour closed.
diff --git a/e2e/_utils.ts b/e2e/_utils.ts
index 8495bb7add..a87c54ee22 100644
--- a/e2e/_utils.ts
+++ b/e2e/_utils.ts
@@ -94,6 +94,8 @@ export const rect = async (el: Locator) => {
}
}
+export const approximatelyEqual = (a: number, b: number, tolerance = 1) => Math.abs(a - b) <= tolerance
+
export async function isInViewport(viewport: Locator, el: Locator) {
const bbox = await rect(el)
const viewportBbox = await rect(viewport)
diff --git a/e2e/models/tour.model.ts b/e2e/models/tour.model.ts
index fe5014504b..a804428585 100644
--- a/e2e/models/tour.model.ts
+++ b/e2e/models/tour.model.ts
@@ -1,5 +1,5 @@
import { expect, type Page } from "@playwright/test"
-import { rect, textSelection } from "../_utils"
+import { approximatelyEqual, rect, textSelection } from "../_utils"
import { Model } from "./model"
export class TourModel extends Model {
@@ -43,13 +43,15 @@ export class TourModel extends Model {
return rect(this.spotlight)
}
- private isContentCentered() {
- return this.content.evaluate((el) => {
- const rect = el.getBoundingClientRect()
- const centeredX = rect.left + rect.width / 2 === window.innerWidth / 2
- const centeredY = rect.top + rect.height / 2 === window.innerHeight / 2
- return centeredX && centeredY
- })
+ private async isContentCentered() {
+ const contentRect = await rect(this.content)
+ const viewport = this.page.viewportSize()
+ if (!viewport) return false
+
+ return (
+ approximatelyEqual(contentRect.midX, viewport.width / 2) &&
+ approximatelyEqual(contentRect.midY, viewport.height / 2)
+ )
}
clickStart() {
diff --git a/examples/next-ts/pages/dialog/autofocus-attr.tsx b/examples/next-ts/pages/dialog/autofocus-attr.tsx
new file mode 100644
index 0000000000..f0534ea7d5
--- /dev/null
+++ b/examples/next-ts/pages/dialog/autofocus-attr.tsx
@@ -0,0 +1,28 @@
+import * as dialog from "@zag-js/dialog"
+import { Portal, normalizeProps, useMachine } from "@zag-js/react"
+import { useId } from "react"
+
+export default function Page() {
+ const service = useMachine(dialog.machine, { id: useId() })
+ const api = dialog.connect(service, normalizeProps)
+
+ return (
+
+ Open dialog
+ {api.open && (
+
+
+
+
+
Close
+
Edit profile
+
The name input receives focus via data-autofocus.
+
+
Save
+
+
+
+ )}
+
+ )
+}
diff --git a/examples/next-ts/pages/dialog/initial-focus.tsx b/examples/next-ts/pages/dialog/initial-focus.tsx
new file mode 100644
index 0000000000..cfd11e1237
--- /dev/null
+++ b/examples/next-ts/pages/dialog/initial-focus.tsx
@@ -0,0 +1,32 @@
+import * as dialog from "@zag-js/dialog"
+import { Portal, normalizeProps, useMachine } from "@zag-js/react"
+import { useId, useRef } from "react"
+
+export default function Page() {
+ const inputRef = useRef(null)
+ const service = useMachine(dialog.machine, {
+ id: useId(),
+ initialFocusEl: () => inputRef.current,
+ })
+ const api = dialog.connect(service, normalizeProps)
+
+ return (
+
+ Open dialog
+ {api.open && (
+
+
+
+
+
Close
+
Edit profile
+
The name input receives focus via initialFocusEl.
+
+
Save
+
+
+
+ )}
+
+ )
+}
diff --git a/examples/next-ts/pages/dialog/no-autofocus-attr.tsx b/examples/next-ts/pages/dialog/no-autofocus-attr.tsx
new file mode 100644
index 0000000000..2809e216ba
--- /dev/null
+++ b/examples/next-ts/pages/dialog/no-autofocus-attr.tsx
@@ -0,0 +1,33 @@
+import * as dialog from "@zag-js/dialog"
+import { Portal, normalizeProps, useMachine } from "@zag-js/react"
+import { useId } from "react"
+
+export default function Page() {
+ const service = useMachine(dialog.machine, { id: useId() })
+ const api = dialog.connect(service, normalizeProps)
+
+ return (
+
+ Open dialog
+ {api.open && (
+
+
+
+
+
+ Close
+
+
+ ?
+
+
Delete item?
+
Close and help are skipped. Cancel receives focus.
+
Cancel
+
Delete
+
+
+
+ )}
+
+ )
+}
diff --git a/examples/next-ts/pages/tour/with-presence.tsx b/examples/next-ts/pages/tour/with-presence.tsx
new file mode 100644
index 0000000000..22d0cc9767
--- /dev/null
+++ b/examples/next-ts/pages/tour/with-presence.tsx
@@ -0,0 +1,118 @@
+import { Portal, normalizeProps, useMachine } from "@zag-js/react"
+import { tourControls } from "@zag-js/shared"
+import * as tour from "@zag-js/tour"
+import { X } from "lucide-react"
+import { useId } from "react"
+import { Presence } from "../../components/presence"
+import { StateVisualizer } from "../../components/state-visualizer"
+import { Toolbar } from "../../components/toolbar"
+import { useControls } from "../../hooks/use-controls"
+
+const steps: tour.StepDetails[] = [
+ {
+ type: "dialog",
+ id: "intro",
+ title: "Welcome",
+ description: "This tour uses Presence so content stays mounted during the exit animation.",
+ actions: [{ label: "Next", action: "next" }],
+ },
+ {
+ type: "tooltip",
+ id: "search",
+ title: "Search",
+ description: "Find anything from here.",
+ target: () => document.querySelector("#tour-search"),
+ actions: [
+ { label: "Prev", action: "prev" },
+ { label: "Next", action: "next" },
+ ],
+ },
+ {
+ type: "tooltip",
+ id: "profile",
+ title: "Profile",
+ description: "Finish here to watch the tooltip exit animation.",
+ target: () => document.querySelector("#tour-profile"),
+ actions: [
+ { label: "Prev", action: "prev" },
+ { label: "Finish", action: "dismiss" },
+ ],
+ },
+]
+
+export default function Page() {
+ const controls = useControls(tourControls)
+
+ const service = useMachine(tour.machine, {
+ id: useId(),
+ steps,
+ ...controls.context,
+ })
+
+ const api = tour.connect(service, normalizeProps)
+
+ return (
+ <>
+
+
+
api.start()}>Start Tour
+
+ open={String(api.open)} stepId={api.step?.id ?? "null"} title={api.step?.title ?? "(empty)"}
+
+
+
+ Search
+
+
+ Profile
+
+
+
+
+
+
+
+
+
+ {api.step ? (
+ <>
+ {api.step.arrow && (
+
+ )}
+
+ {api.step.title}
+ {api.step.description}
+ {api.getProgressText()}
+
+ {api.step.actions && (
+
+ {api.step.actions.map((action) => (
+
+ {action.label}
+
+ ))}
+
+ )}
+
+
+
+
+ >
+ ) : (
+
+ STEP CLEARED DURING EXIT (bug)
+
+ )}
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/examples/nuxt-ts/app/pages/dialog/autofocus-attr.vue b/examples/nuxt-ts/app/pages/dialog/autofocus-attr.vue
new file mode 100644
index 0000000000..cc5f6de729
--- /dev/null
+++ b/examples/nuxt-ts/app/pages/dialog/autofocus-attr.vue
@@ -0,0 +1,24 @@
+
+
+
+
+ Open dialog
+
+
+
+
+ Close
+ Edit profile
+ The name input receives focus via data-autofocus.
+
+ Save
+
+
+
+
diff --git a/examples/nuxt-ts/app/pages/dialog/initial-focus.vue b/examples/nuxt-ts/app/pages/dialog/initial-focus.vue
new file mode 100644
index 0000000000..788b9ace96
--- /dev/null
+++ b/examples/nuxt-ts/app/pages/dialog/initial-focus.vue
@@ -0,0 +1,28 @@
+
+
+
+
+ Open dialog
+
+
+
+
+ Close
+ Edit profile
+ The name input receives focus via initialFocusEl.
+
+ Save
+
+
+
+
diff --git a/examples/nuxt-ts/app/pages/dialog/no-autofocus-attr.vue b/examples/nuxt-ts/app/pages/dialog/no-autofocus-attr.vue
new file mode 100644
index 0000000000..7ff07c229f
--- /dev/null
+++ b/examples/nuxt-ts/app/pages/dialog/no-autofocus-attr.vue
@@ -0,0 +1,25 @@
+
+
+
+
+ Open dialog
+
+
+
+
+ Close
+ ?
+ Delete item?
+ Close and help are skipped. Cancel receives focus.
+ Cancel
+ Delete
+
+
+
+
diff --git a/examples/preact-ts/src/pages/dialog/autofocus-attr.tsx b/examples/preact-ts/src/pages/dialog/autofocus-attr.tsx
new file mode 100644
index 0000000000..05075cb1c7
--- /dev/null
+++ b/examples/preact-ts/src/pages/dialog/autofocus-attr.tsx
@@ -0,0 +1,27 @@
+import * as dialog from "@zag-js/dialog"
+import { Portal, normalizeProps, useMachine } from "@zag-js/preact"
+
+export default function Page() {
+ const service = useMachine(dialog.machine, { id: "1" })
+ const api = dialog.connect(service, normalizeProps)
+
+ return (
+
+ Open dialog
+ {api.open && (
+
+
+
+
+
Close
+
Edit profile
+
The name input receives focus via data-autofocus.
+
+
Save
+
+
+
+ )}
+
+ )
+}
diff --git a/examples/preact-ts/src/pages/dialog/initial-focus.tsx b/examples/preact-ts/src/pages/dialog/initial-focus.tsx
new file mode 100644
index 0000000000..10fdbcf52e
--- /dev/null
+++ b/examples/preact-ts/src/pages/dialog/initial-focus.tsx
@@ -0,0 +1,32 @@
+import * as dialog from "@zag-js/dialog"
+import { Portal, normalizeProps, useMachine } from "@zag-js/preact"
+import { useRef } from "preact/hooks"
+
+export default function Page() {
+ const inputRef = useRef(null)
+ const service = useMachine(dialog.machine, {
+ id: "1",
+ initialFocusEl: () => inputRef.current,
+ })
+ const api = dialog.connect(service, normalizeProps)
+
+ return (
+
+ Open dialog
+ {api.open && (
+
+
+
+
+
Close
+
Edit profile
+
The name input receives focus via initialFocusEl.
+
+
Save
+
+
+
+ )}
+
+ )
+}
diff --git a/examples/preact-ts/src/pages/dialog/no-autofocus-attr.tsx b/examples/preact-ts/src/pages/dialog/no-autofocus-attr.tsx
new file mode 100644
index 0000000000..591025fb32
--- /dev/null
+++ b/examples/preact-ts/src/pages/dialog/no-autofocus-attr.tsx
@@ -0,0 +1,32 @@
+import * as dialog from "@zag-js/dialog"
+import { Portal, normalizeProps, useMachine } from "@zag-js/preact"
+
+export default function Page() {
+ const service = useMachine(dialog.machine, { id: "1" })
+ const api = dialog.connect(service, normalizeProps)
+
+ return (
+
+ Open dialog
+ {api.open && (
+
+
+
+
+
+ Close
+
+
+ ?
+
+
Delete item?
+
Close and help are skipped. Cancel receives focus.
+
Cancel
+
Delete
+
+
+
+ )}
+
+ )
+}
diff --git a/examples/preact-ts/src/routes.ts b/examples/preact-ts/src/routes.ts
index 1d8e861c19..d0b7c3fbd4 100644
--- a/examples/preact-ts/src/routes.ts
+++ b/examples/preact-ts/src/routes.ts
@@ -152,16 +152,19 @@ export const routes: RouteDefinition[] = [
{ path: "/editable/basic", component: lazy(() => import("./pages/editable/basic")) },
{ path: "/editable/controlled", component: lazy(() => import("./pages/editable/controlled")) },
{ path: "/dialog/basic", component: lazy(() => import("./pages/dialog/basic")) },
+ { path: "/dialog/autofocus-attr", component: lazy(() => import("./pages/dialog/autofocus-attr")) },
{ path: "/dialog/cloudinary", component: lazy(() => import("./pages/dialog/cloudinary")) },
{ path: "/dialog/controlled", component: lazy(() => import("./pages/dialog/controlled")) },
{ path: "/dialog/datepicker", component: lazy(() => import("./pages/dialog/datepicker")) },
{ path: "/dialog/delayed-close", component: lazy(() => import("./pages/dialog/delayed-close")) },
+ { path: "/dialog/initial-focus", component: lazy(() => import("./pages/dialog/initial-focus")) },
{ path: "/dialog/nested", component: lazy(() => import("./pages/dialog/nested")) },
{ path: "/dialog/multiple-trigger", component: lazy(() => import("./pages/dialog/multiple-trigger")) },
{
path: "/dialog/multiple-trigger-controlled",
component: lazy(() => import("./pages/dialog/multiple-trigger-controlled")),
},
+ { path: "/dialog/no-autofocus-attr", component: lazy(() => import("./pages/dialog/no-autofocus-attr")) },
{ path: "/dialog/popover-nested", component: lazy(() => import("./pages/dialog/popover-nested")) },
{ path: "/dialog/scroll-outside", component: lazy(() => import("./pages/dialog/scroll-outside")) },
{ path: "/hover-card/basic", component: lazy(() => import("./pages/hover-card/basic")) },
diff --git a/examples/solid-ts/src/routes/dialog/autofocus-attr.tsx b/examples/solid-ts/src/routes/dialog/autofocus-attr.tsx
new file mode 100644
index 0000000000..3fea4a78cc
--- /dev/null
+++ b/examples/solid-ts/src/routes/dialog/autofocus-attr.tsx
@@ -0,0 +1,29 @@
+import * as dialog from "@zag-js/dialog"
+import { normalizeProps, useMachine } from "@zag-js/solid"
+import { Show, createMemo, createUniqueId } from "solid-js"
+import { Portal } from "solid-js/web"
+
+export default function Page() {
+ const service = useMachine(dialog.machine, { id: createUniqueId() })
+ const api = createMemo(() => dialog.connect(service, normalizeProps))
+
+ return (
+
+ Open dialog
+
+
+
+
+
+
Close
+
Edit profile
+
The name input receives focus via data-autofocus.
+
+
Save
+
+
+
+
+
+ )
+}
diff --git a/examples/solid-ts/src/routes/dialog/initial-focus.tsx b/examples/solid-ts/src/routes/dialog/initial-focus.tsx
new file mode 100644
index 0000000000..f6faaddcbf
--- /dev/null
+++ b/examples/solid-ts/src/routes/dialog/initial-focus.tsx
@@ -0,0 +1,33 @@
+import * as dialog from "@zag-js/dialog"
+import { normalizeProps, useMachine } from "@zag-js/solid"
+import { Show, createMemo, createUniqueId } from "solid-js"
+import { Portal } from "solid-js/web"
+
+export default function Page() {
+ let inputRef: HTMLInputElement | undefined
+ const service = useMachine(dialog.machine, {
+ id: createUniqueId(),
+ initialFocusEl: () => inputRef ?? null,
+ })
+ const api = createMemo(() => dialog.connect(service, normalizeProps))
+
+ return (
+
+ Open dialog
+
+
+
+
+
+
Close
+
Edit profile
+
The name input receives focus via initialFocusEl.
+
+
Save
+
+
+
+
+
+ )
+}
diff --git a/examples/solid-ts/src/routes/dialog/no-autofocus-attr.tsx b/examples/solid-ts/src/routes/dialog/no-autofocus-attr.tsx
new file mode 100644
index 0000000000..e8de1f6b22
--- /dev/null
+++ b/examples/solid-ts/src/routes/dialog/no-autofocus-attr.tsx
@@ -0,0 +1,34 @@
+import * as dialog from "@zag-js/dialog"
+import { normalizeProps, useMachine } from "@zag-js/solid"
+import { Show, createMemo, createUniqueId } from "solid-js"
+import { Portal } from "solid-js/web"
+
+export default function Page() {
+ const service = useMachine(dialog.machine, { id: createUniqueId() })
+ const api = createMemo(() => dialog.connect(service, normalizeProps))
+
+ return (
+
+ Open dialog
+
+
+
+
+
+
+ Close
+
+
+ ?
+
+
Delete item?
+
Close and help are skipped. Cancel receives focus.
+
Cancel
+
Delete
+
+
+
+
+
+ )
+}
diff --git a/examples/svelte-ts/src/routes/dialog/autofocus-attr/+page.svelte b/examples/svelte-ts/src/routes/dialog/autofocus-attr/+page.svelte
new file mode 100644
index 0000000000..9bc945a656
--- /dev/null
+++ b/examples/svelte-ts/src/routes/dialog/autofocus-attr/+page.svelte
@@ -0,0 +1,27 @@
+
+
+
+ Open dialog
+
+
+
+
+ Close
+ Edit profile
+ The name input receives focus via data-autofocus.
+
+ Save
+
+
+
+
diff --git a/examples/svelte-ts/src/routes/dialog/initial-focus/+page.svelte b/examples/svelte-ts/src/routes/dialog/initial-focus/+page.svelte
new file mode 100644
index 0000000000..71f65fe463
--- /dev/null
+++ b/examples/svelte-ts/src/routes/dialog/initial-focus/+page.svelte
@@ -0,0 +1,32 @@
+
+
+
+ Open dialog
+
+
+
+
+ Close
+ Edit profile
+ The name input receives focus via initialFocusEl.
+
+ Save
+
+
+
+
diff --git a/examples/svelte-ts/src/routes/dialog/no-autofocus-attr/+page.svelte b/examples/svelte-ts/src/routes/dialog/no-autofocus-attr/+page.svelte
new file mode 100644
index 0000000000..61489d402b
--- /dev/null
+++ b/examples/svelte-ts/src/routes/dialog/no-autofocus-attr/+page.svelte
@@ -0,0 +1,28 @@
+
+
+
+ Open dialog
+
+
+
+
+ Close
+ ?
+ Delete item?
+ Close and help are skipped. Cancel receives focus.
+ Cancel
+ Delete
+
+
+
+
diff --git a/packages/machines/dialog/src/dialog.machine.ts b/packages/machines/dialog/src/dialog.machine.ts
index 79a5c7adec..5be726b176 100644
--- a/packages/machines/dialog/src/dialog.machine.ts
+++ b/packages/machines/dialog/src/dialog.machine.ts
@@ -167,7 +167,11 @@ export const machine = createMachine({
return trapFocus(contentEl, {
preventScroll: true,
returnFocusOnDeactivate: !!prop("restoreFocus"),
- initialFocus: prop("initialFocusEl"),
+ initialFocus: () =>
+ getInitialFocus({
+ root: dom.getContentEl(scope),
+ getInitialEl: prop("initialFocusEl"),
+ }),
setReturnFocus: (el) => {
// If finalFocusEl is provided, use it
const finalFocusEl = prop("finalFocusEl")?.()
diff --git a/packages/machines/drawer/src/drawer.machine.ts b/packages/machines/drawer/src/drawer.machine.ts
index 5a0e94128b..17496b9876 100644
--- a/packages/machines/drawer/src/drawer.machine.ts
+++ b/packages/machines/drawer/src/drawer.machine.ts
@@ -758,7 +758,11 @@ export const machine = createMachine({
return trapFocus(contentEl, {
preventScroll: true,
returnFocusOnDeactivate: !!prop("restoreFocus"),
- initialFocus: prop("initialFocusEl"),
+ initialFocus: () =>
+ getInitialFocus({
+ root: dom.getContentEl(scope),
+ getInitialEl: prop("initialFocusEl"),
+ }),
setReturnFocus: (el) => {
const finalFocusEl = prop("finalFocusEl")?.()
if (finalFocusEl) return finalFocusEl
diff --git a/packages/machines/tour/README.md b/packages/machines/tour/README.md
index d3849e99c1..d90564b052 100644
--- a/packages/machines/tour/README.md
+++ b/packages/machines/tour/README.md
@@ -1,6 +1,10 @@
# @zag-js/tour
-Core logic for the tour widget implemented as a state machine
+Core logic for the tour widget implemented as a state machine.
+
+## Documentation
+
+https://zagjs.com/components/tour
## Installation
@@ -17,41 +21,3 @@ Yes please! See the [contributing guidelines](https://github.com/chakra-ui/zag/b
## Licence
This project is licensed under the terms of the [MIT license](https://github.com/chakra-ui/zag/blob/main/LICENSE).
-
-### Docs Examples
-
-Useful for designing guided product tours, feature highlights, contextual help in your application.
-
-- https://design.mindsphere.io/patterns/guided-tour.html
-- https://design.mindsphere.io/patterns/guided-tour.html
-- https://codepen.io/collection/DyPkzY?cursor=eyJwYWdlIjoxfQ==
-
-- Changing the placement of a tour, per step
-- Exiting the tour on interaction outside
-- Styling the overlay background
-- Adding a stroke around around the overlay
-- Highlighting with no target
-- Showing tour progress
-- Removing the overlay
-- Disabling keyboard navigation
-- Handling Cross frame elements (iframes)
-- RTL Support (right to left)
-- Customizing the accessibility labels
-- Asynchronous steps using `effects`
- - Lazy-Loading
- - Show step after a delay
-- Celebrating the end of the tour
- - End the tour with a celebration
-- Wait for Navigation and Content to Load
-- Accessibility
- - Keyboard navigation
- - Screen reader support
- - Focus management
- - Live region for content updates
-
-### Pro Examples
-
-- Customizable Tour Templates
-- Analytics Integration
-- Multi-Language Support
-- Advanced Branching Logic
diff --git a/packages/machines/tour/package.json b/packages/machines/tour/package.json
index add4ee77d0..a94d99391c 100644
--- a/packages/machines/tour/package.json
+++ b/packages/machines/tour/package.json
@@ -16,10 +16,10 @@
"intro"
],
"author": "Segun Adebayo ",
- "homepage": "https://github.com/chakra-ui/zag#readme",
+ "homepage": "https://zagjs.com/components/tour",
"license": "MIT",
"main": "src/index.ts",
- "repository": "https://github.com/chakra-ui/zag/tree/main/packages/tour",
+ "repository": "https://github.com/chakra-ui/zag/tree/main/packages/machines/tour",
"sideEffects": false,
"files": [
"dist"
diff --git a/packages/machines/tour/src/tour.connect.ts b/packages/machines/tour/src/tour.connect.ts
index 3eb23aa0c2..15b80ad805 100644
--- a/packages/machines/tour/src/tour.connect.ts
+++ b/packages/machines/tour/src/tour.connect.ts
@@ -27,10 +27,12 @@ export function connect(service: TourService, normalize: No
const placement = context.get("currentPlacement")
const placementSide = isTooltipPlacement(placement) ? getPlacementSide(placement) : undefined
const targetRect = context.get("targetRect")
+ const floatingOffset = context.get("floatingOffset")
+ const tooltipPositioned = isTooltipStep(step) && floatingOffset != null
const popperStyles = getPlacementStyles({
strategy: "absolute",
- placement: isTooltipPlacement(placement) ? placement : undefined,
+ placement: tooltipPositioned && isTooltipPlacement(placement) ? placement : undefined,
})
const clipPath = getClipPath({
@@ -136,11 +138,15 @@ export function connect(service: TourService, normalize: No
hidden: !open || !step?.target?.(),
style: {
"--tour-layer": 1,
+ "--spotlight-x": toPx(targetRect.x),
+ "--spotlight-y": toPx(targetRect.y),
+ "--spotlight-width": toPx(targetRect.width),
+ "--spotlight-height": toPx(targetRect.height),
position: "absolute",
- width: toPx(targetRect.width),
- height: toPx(targetRect.height),
- left: toPx(targetRect.x),
- top: toPx(targetRect.y),
+ width: "var(--spotlight-width)",
+ height: "var(--spotlight-height)",
+ left: "var(--spotlight-x)",
+ top: "var(--spotlight-y)",
borderRadius: toPx(prop("spotlightRadius")),
pointerEvents: "none",
},
@@ -163,7 +169,15 @@ export function connect(service: TourService, normalize: No
"data-side": placementSide,
style: {
"--tour-layer": 2,
- ...(step?.type === "tooltip" && popperStyles.floating),
+ ...(isTooltipStep(step) && {
+ ...popperStyles.floating,
+ ...(floatingOffset && {
+ "--x": toPx(floatingOffset.x),
+ "--y": toPx(floatingOffset.y),
+ }),
+ "--z-index": "calc(var(--tour-layer) + var(--tour-z-index))",
+ }),
+ ...(!open && { pointerEvents: "none" }),
},
})
},
@@ -173,8 +187,8 @@ export function connect(service: TourService, normalize: No
id: dom.getArrowId(scope),
...parts.arrow.attrs,
dir: prop("dir"),
- hidden: step?.type !== "tooltip",
- style: step?.type === "tooltip" ? popperStyles.arrow : undefined,
+ hidden: !tooltipPositioned,
+ style: tooltipPositioned ? popperStyles.arrow : undefined,
opacity: hasTarget ? undefined : 0,
})
},
diff --git a/packages/machines/tour/src/tour.dom.ts b/packages/machines/tour/src/tour.dom.ts
index 5b696dde80..758ccd18cf 100644
--- a/packages/machines/tour/src/tour.dom.ts
+++ b/packages/machines/tour/src/tour.dom.ts
@@ -1,5 +1,5 @@
import type { Scope } from "@zag-js/core"
-import { getComputedStyle, raf } from "@zag-js/dom-query"
+import { getComputedStyle, raf, setStyleProperty } from "@zag-js/dom-query"
export const getPositionerId = (ctx: Scope) => ctx.ids?.positioner ?? `tour-positioner-${ctx.id}`
export const getContentId = (ctx: Scope) => ctx.ids?.content ?? `tour-content-${ctx.id}`
@@ -12,22 +12,26 @@ export const getPositionerEl = (ctx: Scope) => ctx.getById(getPositionerId(ctx))
export const getBackdropEl = (ctx: Scope) => ctx.getById(getBackdropId(ctx))
export function syncZIndex(scope: Scope) {
- return raf(() => {
- // sync z-index of positioner with content
+ const restores: VoidFunction[] = []
+
+ const cancel = raf(() => {
const contentEl = getContentEl(scope)
if (!contentEl) return
- const styles = getComputedStyle(contentEl)
- const positionerEl = getPositionerEl(scope)
- const backdropEl = getBackdropEl(scope)
+ const zIndex = getComputedStyle(contentEl).zIndex
+ if (!zIndex || zIndex === "auto") return
- if (positionerEl) {
- positionerEl.style.setProperty("--z-index", styles.zIndex)
- positionerEl.style.setProperty("z-index", "var(--z-index)")
- }
+ const positionerEl = getPositionerEl(scope)
+ if (!positionerEl) return
- if (backdropEl) {
- backdropEl.style.setProperty("--z-index", styles.zIndex)
- }
+ restores.push(
+ setStyleProperty(positionerEl, "--z-index", zIndex),
+ setStyleProperty(positionerEl, "z-index", "var(--z-index)"),
+ )
})
+
+ return () => {
+ cancel()
+ restores.forEach((restore) => restore())
+ }
}
diff --git a/packages/machines/tour/src/tour.machine.ts b/packages/machines/tour/src/tour.machine.ts
index f2ca1d9235..e717e81fda 100644
--- a/packages/machines/tour/src/tour.machine.ts
+++ b/packages/machines/tour/src/tour.machine.ts
@@ -7,7 +7,7 @@ import { getPlacement } from "@zag-js/popper"
import { isEqual, isString, nextIndex, prevIndex, warn } from "@zag-js/utils"
import * as dom from "./tour.dom"
import type { StepDetails, StepEffectArgs, StepEffectCleanup, StepPlacement, TourSchema } from "./tour.types"
-import { isEventInRect, offset, type Rect, type Size } from "./utils/rect"
+import { isEventInRect, offset, type Point, type Rect, type Size } from "./utils/rect"
import {
findStep,
findStepIndex,
@@ -78,6 +78,9 @@ export const machine = createMachine({
currentPlacement: bindable(() => ({
defaultValue: undefined,
})),
+ floatingOffset: bindable(() => ({
+ defaultValue: null,
+ })),
}
},
@@ -179,7 +182,7 @@ export const machine = createMachine({
entry: ["validateSteps"],
on: {
START: {
- actions: ["setInitialStep", "invokeOnStart"],
+ actions: ["clearStep", "setInitialStep", "invokeOnStart"],
},
},
},
@@ -201,16 +204,16 @@ export const machine = createMachine({
{
guard: "isLastStep",
target: "tourInactive",
- actions: ["cleanupAll", "invokeOnDismiss", "invokeOnComplete", "clearStep"],
+ actions: ["cleanupAll", "invokeOnDismiss", "invokeOnComplete"],
},
{
target: "tourInactive",
- actions: ["cleanupAll", "invokeOnDismiss", "clearStep"],
+ actions: ["cleanupAll", "invokeOnDismiss"],
},
],
SKIP: {
target: "tourInactive",
- actions: ["cleanupAll", "invokeOnSkip", "clearStep"],
+ actions: ["cleanupAll", "invokeOnSkip"],
},
},
@@ -298,6 +301,8 @@ export const machine = createMachine({
context.set("targetRect", { width: 0, height: 0, x: 0, y: 0 })
context.set("resolvedTarget", null)
+ context.set("currentPlacement", undefined)
+ context.set("floatingOffset", null)
refs.set("_internalChange", true)
context.set("stepId", null)
@@ -531,10 +536,12 @@ export const machine = createMachine({
context.set("currentPlacement", step.placement ?? "bottom")
if (isDialogStep(step)) {
+ context.set("floatingOffset", null)
return dom.syncZIndex(scope)
}
if (!isTooltipStep(step)) {
+ context.set("floatingOffset", null)
return
}
@@ -545,7 +552,8 @@ export const machine = createMachine({
strategy: "absolute",
gutter: 10,
offset: step.offset,
- restoreStyles: true,
+ restoreStyles: false,
+ applyStyles: false,
getAnchorRect(el) {
if (!isHTMLElement(el)) return null
const rect = el.getBoundingClientRect()
@@ -555,6 +563,7 @@ export const machine = createMachine({
const { rects } = data.middlewareData
context.set("currentPlacement", data.placement)
context.set("targetRect", rects.reference)
+ context.set("floatingOffset", { x: data.x, y: data.y })
},
})
},
@@ -638,7 +647,7 @@ function performStepTransition(params: Params, idx: number) {
}
function createEffectUtilities(params: Params, step: StepDetails, idx: number): StepEffectArgs {
- const { context, computed, refs, send, prop } = params
+ const { context, computed, refs, send } = params
const steps = context.get("steps")
return {
@@ -667,9 +676,7 @@ function createEffectUtilities(params: Params, step: StepDetails, id
performStepTransition(params, targetIdx)
},
dismiss: () => {
- refs.set("_internalChange", true)
- context.set("stepId", null)
- prop("onStatusChange")?.({ status: "dismissed", stepId: null, stepIndex: -1 })
+ send({ type: "DISMISS", src: "step-effect" })
},
target: step.target,
}
diff --git a/packages/machines/tour/src/tour.types.ts b/packages/machines/tour/src/tour.types.ts
index 3596681147..2c05dd6d20 100644
--- a/packages/machines/tour/src/tour.types.ts
+++ b/packages/machines/tour/src/tour.types.ts
@@ -238,6 +238,11 @@ interface PrivateContext {
* The resolved target element
*/
resolvedTarget: HTMLElement | null
+ /**
+ * The computed floating position for tooltip steps.
+ * Kept after close so Presence exit animations stay anchored.
+ */
+ floatingOffset: Point | null
/**
* The id of the current step
*/
diff --git a/packages/utilities/dom-query/src/initial-focus.ts b/packages/utilities/dom-query/src/initial-focus.ts
index 86e94f1698..20825ff60d 100644
--- a/packages/utilities/dom-query/src/initial-focus.ts
+++ b/packages/utilities/dom-query/src/initial-focus.ts
@@ -13,16 +13,19 @@ export function getInitialFocus(options: InitialFocusOptions): HTMLElement | und
if (!enabled) return
- let node: HTMLElement | null | undefined = null
+ // 1. explicit override (e.g. alertdialog → close button)
+ let node: HTMLElement | null | undefined = typeof getInitialEl === "function" ? getInitialEl() : getInitialEl
- node ||= typeof getInitialEl === "function" ? getInitialEl() : getInitialEl
+ // 2. opt-in wins over skip
node ||= root?.querySelector("[data-autofocus],[autofocus]")
+ // 3. first tabbable that isn't opted out of autofocus
if (!node) {
- const tabbables = getTabbables(root)
- node = filter ? tabbables.filter(filter)[0] : tabbables[0]
+ const tabbables = getTabbables(root).filter((el) => (filter ? filter(el) : true))
+ node = tabbables.find((el) => !el.hasAttribute("data-no-autofocus"))
}
+ // 4. content root fallback (e.g. all chrome controls opted out)
return node || root || undefined
}
diff --git a/packages/utilities/dom-query/tests/initial-focus.test.ts b/packages/utilities/dom-query/tests/initial-focus.test.ts
new file mode 100644
index 0000000000..37ced3775f
--- /dev/null
+++ b/packages/utilities/dom-query/tests/initial-focus.test.ts
@@ -0,0 +1,114 @@
+// @vitest-environment jsdom
+
+import { afterEach, describe, expect, it } from "vitest"
+import { getInitialFocus } from "../src"
+
+function markVisible(element: T): T {
+ Object.defineProperty(element, "offsetWidth", { configurable: true, get: () => 1 })
+ Object.defineProperty(element, "offsetHeight", { configurable: true, get: () => 1 })
+ Object.defineProperty(element, "getClientRects", {
+ configurable: true,
+ value: () => [{ width: 1, height: 1 }],
+ })
+ return element
+}
+
+function createButton(text: string) {
+ const button = markVisible(document.createElement("button"))
+ button.type = "button"
+ button.textContent = text
+ return button
+}
+
+function createRoot() {
+ const root = markVisible(document.createElement("div"))
+ root.tabIndex = -1
+ document.body.append(root)
+ return root
+}
+
+describe("getInitialFocus", () => {
+ afterEach(() => {
+ document.body.innerHTML = ""
+ })
+
+ it("returns undefined when disabled", () => {
+ const root = createRoot()
+ root.append(createButton("One"))
+
+ expect(getInitialFocus({ root, enabled: false })).toBeUndefined()
+ })
+
+ it("prefers getInitialEl when provided", () => {
+ const root = createRoot()
+ const first = createButton("First")
+ const second = createButton("Second")
+ root.append(first, second)
+
+ expect(getInitialFocus({ root, getInitialEl: () => second })).toBe(second)
+ })
+
+ it("prefers data-autofocus over first tabbable", () => {
+ const root = createRoot()
+ const first = createButton("First")
+ const second = createButton("Second")
+ second.setAttribute("data-autofocus", "")
+ root.append(first, second)
+
+ expect(getInitialFocus({ root })).toBe(second)
+ })
+
+ it("skips elements with data-no-autofocus", () => {
+ const root = createRoot()
+ const close = createButton("Close")
+ close.setAttribute("data-no-autofocus", "")
+ const help = createButton("Help")
+ help.setAttribute("data-no-autofocus", "")
+ const submit = createButton("Submit")
+ root.append(close, help, submit)
+
+ expect(getInitialFocus({ root })).toBe(submit)
+ })
+
+ it("lets data-autofocus win over data-no-autofocus", () => {
+ const root = createRoot()
+ const close = createButton("Close")
+ close.setAttribute("data-no-autofocus", "")
+ close.setAttribute("data-autofocus", "")
+ const submit = createButton("Submit")
+ root.append(close, submit)
+
+ expect(getInitialFocus({ root })).toBe(close)
+ })
+
+ it("falls back to root when every tabbable has data-no-autofocus", () => {
+ const root = createRoot()
+ const close = createButton("Close")
+ close.setAttribute("data-no-autofocus", "")
+ const help = createButton("Help")
+ help.setAttribute("data-no-autofocus", "")
+ root.append(close, help)
+
+ expect(getInitialFocus({ root })).toBe(root)
+ })
+
+ it("falls back to root when there are no tabbables", () => {
+ const root = createRoot()
+ expect(getInitialFocus({ root })).toBe(root)
+ })
+
+ it("respects the filter option", () => {
+ const root = createRoot()
+ const menuitem = createButton("Item")
+ menuitem.setAttribute("role", "menuitem")
+ const other = createButton("Other")
+ root.append(menuitem, other)
+
+ expect(
+ getInitialFocus({
+ root,
+ filter: (el) => !el.role?.startsWith("menuitem"),
+ }),
+ ).toBe(other)
+ })
+})
diff --git a/packages/utilities/popper/src/get-placement.ts b/packages/utilities/popper/src/get-placement.ts
index a545fb661c..6c43911315 100644
--- a/packages/utilities/popper/src/get-placement.ts
+++ b/packages/utilities/popper/src/get-placement.ts
@@ -12,6 +12,7 @@ const defaultOptions: PositioningOptions = {
placement: "bottom",
listeners: true,
restoreStyles: false,
+ applyStyles: true,
gutter: 8,
flip: true,
slide: true,
@@ -316,11 +317,12 @@ function getPlacementImpl(
strategy,
})
- onComplete?.(pos)
-
const win = getWindow(floating)
const x = roundByDpr(win, pos.x)
const y = roundByDpr(win, pos.y)
+ onComplete?.({ ...pos, x, y })
+
+ if (options.applyStyles === false) return
if (!isApproximatelyEqual(lastX, x)) {
floating.style.setProperty("--x", `${x}px`)
diff --git a/packages/utilities/popper/src/types.ts b/packages/utilities/popper/src/types.ts
index fdb12ef0aa..ebdec5dbde 100644
--- a/packages/utilities/popper/src/types.ts
+++ b/packages/utilities/popper/src/types.ts
@@ -21,6 +21,12 @@ export interface PositioningOptions {
* Whether styles applied by the positioning utility should be restored on cleanup.
*/
restoreStyles?: boolean | undefined
+ /**
+ * Whether to apply computed position styles (`--x`, `--y`, `--z-index`) to the floating element.
+ * Set to `false` when the consumer applies these from context (e.g. for exit animations).
+ * @default true
+ */
+ applyStyles?: boolean | undefined
/**
* Whether the popover should be hidden when the reference element is detached
*/
diff --git a/shared/src/css/tour.css b/shared/src/css/tour.css
index d2ef3bac18..acc3636f20 100644
--- a/shared/src/css/tour.css
+++ b/shared/src/css/tour.css
@@ -1,3 +1,13 @@
+:root {
+ --tour-z-index: 100;
+}
+
+[data-scope="tour"][data-part="backdrop"],
+[data-scope="tour"][data-part="spotlight"],
+[data-scope="tour"][data-part="positioner"] {
+ z-index: calc(var(--tour-layer) + var(--tour-z-index));
+}
+
[data-scope="tour"][data-part="positioner"][data-type="floating"] {
position: absolute;
}
@@ -37,6 +47,65 @@
border-radius: 4px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
width: 300px;
+ z-index: calc(var(--tour-layer) + var(--tour-z-index));
+}
+
+[data-scope="tour"][data-part="content"][data-state="open"] {
+ animation: tourFadeIn 0.2s ease-out;
+}
+
+[data-scope="tour"][data-part="content"][data-state="closed"] {
+ animation: tourFadeOut 0.15s ease-in;
+}
+
+[data-scope="tour"][data-part="backdrop"][data-state="open"],
+[data-scope="tour"][data-part="spotlight"][data-state="open"] {
+ animation: tourBackdropIn 0.2s ease-out;
+}
+
+[data-scope="tour"][data-part="backdrop"][data-state="closed"],
+[data-scope="tour"][data-part="spotlight"][data-state="closed"] {
+ animation: tourBackdropOut 0.15s ease-in;
+}
+
+@keyframes tourFadeIn {
+ from {
+ opacity: 0;
+ transform: scale(0.96);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes tourFadeOut {
+ from {
+ opacity: 1;
+ transform: scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: scale(0.96);
+ }
+}
+
+@keyframes tourBackdropIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes tourBackdropOut {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
}
[data-scope="tour"][data-part="content"][data-type="dialog"] {
@@ -75,6 +144,11 @@
[data-scope="tour"][data-part="spotlight"] {
border: 3px solid pink;
+ transition:
+ left 0.2s ease,
+ top 0.2s ease,
+ width 0.2s ease,
+ height 0.2s ease;
}
[data-scope="tour"][data-part="close-trigger"] {
diff --git a/website/app/community/page.tsx b/website/app/community/page.tsx
index 4b82b40bc3..2eb027f920 100644
--- a/website/app/community/page.tsx
+++ b/website/app/community/page.tsx
@@ -7,7 +7,7 @@ import {
recordings,
teamMembers,
} from "lib/community"
-import type { Metadata } from "next"
+import { createPageMetadata } from "lib/seo"
import { FaGithub, FaNpm } from "react-icons/fa6"
import { Box, Flex, Grid, Stack, styled } from "styled-system/jsx"
import { CommunityLink } from "./community-link"
@@ -16,11 +16,12 @@ import { ProfileItem } from "./profile-item"
import { RecordingItem } from "./recording-item"
import { getCommunityStats } from "./stats"
-export const metadata: Metadata = {
+export const metadata = createPageMetadata({
title: "Community",
description:
"Connect with the Zag.js community, meet the team, watch recordings, and discover ecosystem projects.",
-}
+ path: "/community",
+})
function SectionHeading(props: { title: string; description: string }) {
return (
diff --git a/website/app/components/[...slug]/page.tsx b/website/app/components/[...slug]/page.tsx
index 0f94da5e41..469a2ad383 100644
--- a/website/app/components/[...slug]/page.tsx
+++ b/website/app/components/[...slug]/page.tsx
@@ -3,6 +3,7 @@ import {
getComponentDoc,
getComponentPaths,
} from "lib/contentlayer-utils"
+import { createPageMetadata } from "lib/seo"
import type { Metadata } from "next"
import ComponentPageClient from "./client"
@@ -17,10 +18,13 @@ export async function generateMetadata({
const { slug: componentSlug } = extractParams(slug)
const doc = getComponentDoc(componentSlug)
- return {
+ if (!doc) return {}
+
+ return createPageMetadata({
title: doc.title,
description: doc.description,
- }
+ path: `/components/${componentSlug}`,
+ })
}
export async function generateStaticParams() {
diff --git a/website/app/guides/[slug]/page.tsx b/website/app/guides/[slug]/page.tsx
index 7efcf5377d..a69f184b71 100644
--- a/website/app/guides/[slug]/page.tsx
+++ b/website/app/guides/[slug]/page.tsx
@@ -1,4 +1,5 @@
import { getGuideDoc, getGuidePaths } from "lib/contentlayer-utils"
+import { createPageMetadata } from "lib/seo"
import type { Metadata } from "next"
import GuidePageClient from "./client"
@@ -12,10 +13,13 @@ export async function generateMetadata({
const { slug } = await params
const doc = getGuideDoc(slug)
- return {
+ if (!doc) return {}
+
+ return createPageMetadata({
title: doc.title,
description: doc.description,
- }
+ path: `/guides/${slug}`,
+ })
}
export async function generateStaticParams() {
diff --git a/website/app/overview/[slug]/page.tsx b/website/app/overview/[slug]/page.tsx
index 850fbdce7c..7247828764 100644
--- a/website/app/overview/[slug]/page.tsx
+++ b/website/app/overview/[slug]/page.tsx
@@ -1,4 +1,5 @@
import { getOverviewDoc, getOverviewPaths } from "lib/contentlayer-utils"
+import { createPageMetadata } from "lib/seo"
import type { Metadata } from "next"
import OverviewPageClient from "./client"
@@ -12,10 +13,13 @@ export async function generateMetadata({
const { slug } = await params
const doc = getOverviewDoc(slug)
- return {
+ if (!doc) return {}
+
+ return createPageMetadata({
title: doc.title,
description: doc.description,
- }
+ path: `/overview/${slug}`,
+ })
}
export async function generateStaticParams() {
diff --git a/website/app/robots.ts b/website/app/robots.ts
new file mode 100644
index 0000000000..fcb3b7a316
--- /dev/null
+++ b/website/app/robots.ts
@@ -0,0 +1,13 @@
+import type { MetadataRoute } from "next"
+import siteConfig from "site.config"
+
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: {
+ userAgent: "*",
+ allow: "/",
+ },
+ sitemap: `${siteConfig.url}/sitemap.xml`,
+ host: siteConfig.url,
+ }
+}
diff --git a/website/app/showcase/page.tsx b/website/app/showcase/page.tsx
index c2ff80529a..4669696a82 100644
--- a/website/app/showcase/page.tsx
+++ b/website/app/showcase/page.tsx
@@ -2,16 +2,17 @@ import { Footer } from "components/footer"
import { TopNav } from "components/nav/top-nav"
import { Section } from "components/ui/section"
import { showcaseItems, type ShowcaseItem } from "lib/showcase"
-import type { Metadata } from "next"
+import { createPageMetadata } from "lib/seo"
import Image from "next/image"
import { css } from "styled-system/css"
import { Box, Grid, Stack, styled } from "styled-system/jsx"
-export const metadata: Metadata = {
+export const metadata = createPageMetadata({
title: "Showcase",
description:
"Discover projects and design systems built with Zag.js state machines",
-}
+ path: "/showcase",
+})
function ShowcaseCard({ item }: { item: ShowcaseItem }) {
return (
diff --git a/website/app/sitemap.ts b/website/app/sitemap.ts
new file mode 100644
index 0000000000..053cb8b25a
--- /dev/null
+++ b/website/app/sitemap.ts
@@ -0,0 +1,49 @@
+import {
+ getComponentPaths,
+ getGuidePaths,
+ getOverviewPaths,
+ getUtilityPaths,
+} from "lib/contentlayer-utils"
+import type { MetadataRoute } from "next"
+import siteConfig from "site.config"
+
+export default function sitemap(): MetadataRoute.Sitemap {
+ const lastModified = new Date()
+
+ const staticRoutes = ["", "/community", "/showcase"].map((path) => ({
+ url: `${siteConfig.url}${path}`,
+ lastModified,
+ }))
+
+ // Prefer canonical component URLs (no framework prefix) for search engines.
+ const componentRoutes = getComponentPaths()
+ .map(({ params }) => params.slug)
+ .filter((slug) => slug.length === 1)
+ .map((slug) => ({
+ url: `${siteConfig.url}/components/${slug[0]}`,
+ lastModified,
+ }))
+
+ const overviewRoutes = getOverviewPaths().map(({ slug }) => ({
+ url: `${siteConfig.url}/overview/${slug}`,
+ lastModified,
+ }))
+
+ const guideRoutes = getGuidePaths().map(({ slug }) => ({
+ url: `${siteConfig.url}/guides/${slug}`,
+ lastModified,
+ }))
+
+ const utilityRoutes = getUtilityPaths().map(({ slug }) => ({
+ url: `${siteConfig.url}/utilities/${slug}`,
+ lastModified,
+ }))
+
+ return [
+ ...staticRoutes,
+ ...componentRoutes,
+ ...overviewRoutes,
+ ...guideRoutes,
+ ...utilityRoutes,
+ ]
+}
diff --git a/website/app/utilities/[slug]/page.tsx b/website/app/utilities/[slug]/page.tsx
index 33dab4197d..ae14546a52 100644
--- a/website/app/utilities/[slug]/page.tsx
+++ b/website/app/utilities/[slug]/page.tsx
@@ -1,4 +1,5 @@
import { getUtilityDoc, getUtilityPaths } from "lib/contentlayer-utils"
+import { createPageMetadata } from "lib/seo"
import type { Metadata } from "next"
import UtilityPageClient from "./client"
@@ -12,10 +13,13 @@ export async function generateMetadata({
const { slug } = await params
const doc = getUtilityDoc(slug)
- return {
+ if (!doc) return {}
+
+ return createPageMetadata({
title: doc.title,
description: doc.description,
- }
+ path: `/utilities/${slug}`,
+ })
}
export async function generateStaticParams() {
diff --git a/website/data/components/tour.mdx b/website/data/components/tour.mdx
index 001fe9c9d2..b67ef89ab7 100644
--- a/website/data/components/tour.mdx
+++ b/website/data/components/tour.mdx
@@ -1,6 +1,6 @@
---
title: Tour
-description: Used for creating product tours and onboarding.
+description: Headless product tour and onboarding component for React, Vue, Solid, and Svelte. Step-by-step overlays with tooltip, dialog, and floating steps.
package: "@zag-js/tour"
---
@@ -318,6 +318,27 @@ const service = useMachine(tour.machine, {
})
```
+### Exit animations (Presence)
+
+After the tour closes, `api.step` still references the last step so exit
+animations can keep rendering content. Drive visibility with `api.open` (or
+Presence), and read content from `api.step`.
+
+```tsx
+{
+ api.step && (
+
+
+
+ )
+}
+```
+
+> Don't treat `api.step === null` as "tour closed". Use `api.open` or
+> `onStatusChange` for that.
+
### Customizing progress text
Use `translations.progressText` to customize the progress message.
@@ -417,6 +438,42 @@ can apply specific styles based on the rendered type:
}
```
+### Z-Index
+
+The machine sets `--tour-layer` on the layered parts (`0` backdrop, `1`
+spotlight, `2` positioner). Apply it in CSS so content stacks above the
+backdrop:
+
+```css
+:root {
+ --tour-z-index: 100;
+}
+
+[data-scope="tour"][data-part="backdrop"],
+[data-scope="tour"][data-part="spotlight"],
+[data-scope="tour"][data-part="positioner"],
+[data-scope="tour"][data-part="content"] {
+ z-index: calc(var(--tour-layer) + var(--tour-z-index));
+}
+```
+
+### Spotlight
+
+The spotlight exposes CSS variables you can transition between steps:
+
+```css
+[data-scope="tour"][data-part="spotlight"] {
+ transition:
+ left 0.2s ease,
+ top 0.2s ease,
+ width 0.2s ease,
+ height 0.2s ease;
+}
+```
+
+Variables: `--spotlight-x`, `--spotlight-y`, `--spotlight-width`,
+`--spotlight-height`.
+
### Placement Styles
For floating type tours, you can style based on the placement using the
diff --git a/website/lib/contentlayer-utils.ts b/website/lib/contentlayer-utils.ts
index 12018d9e8e..7278293c22 100644
--- a/website/lib/contentlayer-utils.ts
+++ b/website/lib/contentlayer-utils.ts
@@ -18,13 +18,14 @@ function toParams(str: string | string[]) {
}
export function extractParams(slug: string[]) {
- const [first] = slug
+ const parts = [...slug]
+ const [first] = parts
let result: Framework = "react"
if (isFramework(first)) {
result = first
- slug.shift()
+ parts.shift()
}
- return { framework: result, slug: slug.join("/") }
+ return { framework: result, slug: parts.join("/") }
}
/* -----------------------------------------------------------------------------
diff --git a/website/lib/seo.ts b/website/lib/seo.ts
new file mode 100644
index 0000000000..67a3648212
--- /dev/null
+++ b/website/lib/seo.ts
@@ -0,0 +1,40 @@
+import type { Metadata } from "next"
+import siteConfig from "site.config"
+
+type PageMetadataInput = {
+ title: string
+ description: string
+ path: string
+}
+
+export function createPageMetadata({
+ title,
+ description,
+ path,
+}: PageMetadataInput): Metadata {
+ const url = new URL(path, siteConfig.url).toString()
+
+ return {
+ title,
+ description,
+ alternates: {
+ canonical: path,
+ },
+ openGraph: {
+ type: "website",
+ locale: "en_US",
+ url,
+ title,
+ description,
+ siteName: siteConfig.title,
+ images: siteConfig.seo.openGraph.images,
+ },
+ twitter: {
+ card: "summary_large_image",
+ title,
+ description,
+ site: "@zag_js",
+ creator: "@zag_js",
+ },
+ }
+}