diff --git a/README.md b/README.md
index 7ea0353..14a60cc 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,15 @@ const App = () => {
title: 'Second step',
content: 'Each step can highlight one or more element ids.',
infoBoxAlignment: 'right',
+ options: {
+ infoBoxWidth: '28rem',
+ infoBoxMargin: 16,
+ highlightBorderColor: '#f97316',
+ highlightBorderRadius: 20,
+ labels: {
+ done: 'Ship it',
+ },
+ },
},
],
options: {
@@ -121,9 +130,11 @@ console.log(state.currentStep?.title);
`highLightPadding` expands the highlight frame around the target element. It defaults to `8` pixels and applies to the rendered highlight box as well as the info box anchor position.
-Use `overlayColor`, `highlightBorderColor`, `highlightBorderRadius`, `zIndex`, and `infoBoxWidth` to match the built-in UI to your product without changing the overlay, highlight, or info box structure. When omitted, the existing defaults remain in place.
+Global `options` still define the shared tutorial chrome. Use them for shared defaults such as `overlayColor`, `highlightBorderColor`, `highlightBorderRadius`, `infoBoxHeight`, `infoBoxWidth`, `infoBoxMargin`, `labels`, and `zIndex`.
-Use `labels` to override the built-in button text. The default labels are `이전`, `다음`, `건너뛰기`, and `완료`.
+Use `step.options` when a single step needs different `infoBoxHeight`, `infoBoxWidth`, `infoBoxMargin`, `highlightBorderColor`, `highlightBorderRadius`, or partial `labels`. The fallback order is `step.options` -> global `options` -> built-in defaults. Omitted label keys also follow that order.
+
+`infoBoxAlignment` remains a step field, and `overlayColor` remains global-only so the backdrop stays consistent across the tutorial run.
Keyboard navigation is enabled by default while the overlay is open:
diff --git a/packages/document/src/pages/docs/tutorial-overlay.mdx b/packages/document/src/pages/docs/tutorial-overlay.mdx
index 2454169..cefea5f 100644
--- a/packages/document/src/pages/docs/tutorial-overlay.mdx
+++ b/packages/document/src/pages/docs/tutorial-overlay.mdx
@@ -28,9 +28,9 @@ Because `tutorial.open()` returns a Promise, you can keep ``
The highlight frame uses `options.highLightPadding` to expand around the target. If you do not provide it, the overlay uses an `8px` padding by default.
-Visual customization stays global for now. Use `options.overlayColor`, `options.highlightBorderColor`, `options.highlightBorderRadius`, `options.zIndex`, and `options.infoBoxWidth` when you need the built-in overlay UI to better match your product chrome.
+Tutorial-level `options` still define the shared defaults for the overlay run. Use `step.options` when a single step needs different `infoBoxHeight`, `infoBoxWidth`, `infoBoxMargin`, `highlightBorderColor`, `highlightBorderRadius`, or partial `labels`.
-Use `options.labels` to replace the built-in `이전`, `다음`, `건너뛰기`, and `완료` button text without replacing the UI itself.
+The fallback order is `step.options` -> tutorial `options` -> built-in defaults. `overlayColor` remains global-only so the backdrop stays visually consistent across steps.
By default, the mounted overlay listens for `Escape`, `ArrowLeft`, and `ArrowRight` while it is open. You can disable that with `options.keyboardNavigation = false`.
diff --git a/packages/document/src/pages/docs/tutorial.mdx b/packages/document/src/pages/docs/tutorial.mdx
index 6c5fcb1..b71d4a9 100644
--- a/packages/document/src/pages/docs/tutorial.mdx
+++ b/packages/document/src/pages/docs/tutorial.mdx
@@ -25,6 +25,21 @@ function App() {
content: 'This message is plain text.',
infoBoxAlignment: 'center',
},
+ {
+ targetIds: ['target-id'],
+ title: 'Focused step styling',
+ content: 'This step overrides the shared tutorial styling.',
+ infoBoxAlignment: 'right',
+ options: {
+ infoBoxWidth: '28rem',
+ infoBoxMargin: 16,
+ highlightBorderColor: '#f97316',
+ highlightBorderRadius: 20,
+ labels: {
+ done: 'Ship it',
+ },
+ },
+ },
],
options: {
highLightPadding: 12,
@@ -83,6 +98,7 @@ function App() {
- `title`: optional heading shown in the info box.
- `content`: optional plain text body.
- `infoBoxAlignment`: optional `center`, `left`, or `right`.
+- `options`: optional per-step overrides for `infoBoxHeight`, `infoBoxWidth`, `infoBoxMargin`, `highlightBorderColor`, `highlightBorderRadius`, and partial `labels`.
- `onPrevStep`: optional callback that runs when leaving the step with `tutorial.prev()`.
- `onNextStep`: optional callback that runs when leaving the step with `tutorial.next()`.
@@ -105,19 +121,20 @@ console.log(state.currentStep?.title);
## Tutorial options
- `highLightPadding`: expands the highlight frame around the target in pixels. Defaults to `8`.
-- `infoBoxHeight`: sets the info box height in pixels.
-- `infoBoxWidth`: sets the info box width with a CSS length such as `360` or `'24rem'`. Defaults to `'20rem'`.
-- `infoBoxMargin`: controls the vertical gap between the target and the info box.
+- `infoBoxHeight`: sets the default info box height in pixels and can be overridden per step.
+- `infoBoxWidth`: sets the default info box width with a CSS length such as `360` or `'24rem'`. Defaults to `'20rem'` and can be overridden per step.
+- `infoBoxMargin`: controls the default vertical gap between the target and the info box and can be overridden per step.
- `overlayColor`: sets the backdrop color. Defaults to `rgba(0, 0, 0, 0.5)`.
-- `highlightBorderColor`: sets the highlight border color. Defaults to `#ff0000`.
-- `highlightBorderRadius`: sets the highlight border radius with a CSS length. Defaults to the current padding-based radius.
+- `highlightBorderColor`: sets the default highlight border color. Defaults to `#ff0000` and can be overridden per step.
+- `highlightBorderRadius`: sets the default highlight border radius with a CSS length. Defaults to the current padding-based radius and can be overridden per step.
- `zIndex`: sets the overlay stack level. Defaults to `9999`.
-- `labels`: overrides the built-in `prev`, `next`, `skip`, and `done` button labels.
+- `labels`: overrides the built-in `prev`, `next`, `skip`, and `done` button labels by default and can be partially overridden per step.
- `keyboardNavigation`: enables `Escape`, `ArrowLeft`, and `ArrowRight` shortcuts while the overlay is open. Defaults to `true`.
- `closeOnOverlayClick`: closes the tutorial when the backdrop itself is clicked. Defaults to `false`.
- `onClose`: runs when the active tutorial is closed, including replacement by a newer `tutorial.open()` call.
Keyboard shortcuts are ignored while an `input`, `textarea`, `select`, or `contenteditable` element has focus.
The info box automatically repositions itself to stay within the viewport when the target is close to an edge.
+Step customization resolves in this order: `step.options` -> tutorial `options` -> built-in defaults. `overlayColor`, `highLightPadding`, `keyboardNavigation`, `closeOnOverlayClick`, `zIndex`, and `onClose` stay global for the whole run.
When a tutorial opens, focus moves into the labeled dialog UI and returns to the previously active element after close. The current overlay does not trap focus.
`onClose` remains available for side effects, while the Promise returned by `tutorial.open()` is the async completion hook.
diff --git a/packages/main/src/components/content.tsx b/packages/main/src/components/content.tsx
index d232618..7164656 100644
--- a/packages/main/src/components/content.tsx
+++ b/packages/main/src/components/content.tsx
@@ -2,7 +2,7 @@ import React, { useId } from 'react';
import { useTutorialStore } from '../core/store';
import { skipTutorial, tutorial } from '../core/tutorial';
import { styled } from 'goober';
-import { DEFAULT_INFO_BOX_WIDTH, INFO_BOX_Z_INDEX_OFFSET, getBaseZIndex, getLabels } from '../core/options';
+import { INFO_BOX_Z_INDEX_OFFSET, getBaseZIndex, getInfoBoxWidth, getLabels } from '../core/options';
export const Content = React.forwardRef((_, ref) => {
const {
@@ -12,7 +12,7 @@ export const Content = React.forwardRef((_, ref) => {
const currentStep = steps[index];
const titleId = useId();
const contentId = useId();
- const labels = getLabels(options);
+ const labels = getLabels(options, currentStep);
const handlePrev = () => {
tutorial.prev();
@@ -34,7 +34,7 @@ export const Content = React.forwardRef((_, ref) => {
aria-describedby={currentStep.content ? contentId : undefined}
tabIndex={-1}
style={{
- width: options?.infoBoxWidth ?? DEFAULT_INFO_BOX_WIDTH,
+ width: getInfoBoxWidth(options, currentStep),
zIndex: getBaseZIndex(options) + INFO_BOX_Z_INDEX_OFFSET,
}}
>
diff --git a/packages/main/src/components/tutorial-overlay.tsx b/packages/main/src/components/tutorial-overlay.tsx
index 61bceee..0a5545a 100644
--- a/packages/main/src/components/tutorial-overlay.tsx
+++ b/packages/main/src/components/tutorial-overlay.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
-import type { ElementStyle, Options } from '../core/types';
+import type { ElementStyle, Options, Step } from '../core/types';
import { useTutorialStore } from '../core/store';
import { setup, styled } from 'goober';
import { Content } from './content';
@@ -11,6 +11,10 @@ import {
DEFAULT_Z_INDEX,
HIGHLIGHT_Z_INDEX_OFFSET,
getBaseZIndex,
+ getHighlightBorderColor,
+ getHighlightBorderRadius,
+ getInfoBoxHeight,
+ getInfoBoxMargin,
} from '../core/options';
setup(React.createElement);
@@ -134,7 +138,7 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
});
if (infoBoxAnchor) {
- calculateInfoBoxPosition(infoBoxAnchor, stepConfig.infoBoxAlignment);
+ calculateInfoBoxPosition(infoBoxAnchor, stepConfig);
}
if (currentElements.current.length === 0 || !alreadyCalculated) {
@@ -144,9 +148,10 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
setRectStyles(positions);
}
- function calculateInfoBoxPosition(position: ElementStyle, alignment?: 'center' | 'left' | 'right') {
- const boxHeight = options?.infoBoxHeight ?? 200;
- const margin = options?.infoBoxMargin ?? 30;
+ function calculateInfoBoxPosition(position: ElementStyle, step: Step) {
+ const alignment = step.infoBoxAlignment;
+ const boxHeight = getInfoBoxHeight(options, step);
+ const margin = getInfoBoxMargin(options, step);
const minLeft = window.scrollX + MIN_VIEWPORT_OFFSET;
const maxLeft = window.scrollX + window.innerWidth - MIN_VIEWPORT_OFFSET;
const minTop = window.scrollY + MIN_VIEWPORT_OFFSET;
@@ -308,8 +313,8 @@ export const TutorialOverlay = React.memo(({}: TutorialOverlayProps) => {
key={style.id}
style={{
...style,
- borderColor: options?.highlightBorderColor ?? DEFAULT_HIGHLIGHT_BORDER_COLOR,
- borderRadius: options?.highlightBorderRadius ?? style.borderRadius,
+ borderColor: getHighlightBorderColor(options, steps[index]),
+ borderRadius: getHighlightBorderRadius(options, steps[index], style.borderRadius),
zIndex: baseZIndex + HIGHLIGHT_Z_INDEX_OFFSET,
}}
/>
diff --git a/packages/main/src/core/options.ts b/packages/main/src/core/options.ts
index 8382276..f654b22 100644
--- a/packages/main/src/core/options.ts
+++ b/packages/main/src/core/options.ts
@@ -1,9 +1,11 @@
-import type { Labels, Options } from './types';
+import type { Labels, Options, Step, StyleValue } from './types';
export const DEFAULT_HIGHLIGHT_PADDING = 8;
export const DEFAULT_OVERLAY_COLOR = 'rgba(0, 0, 0, 0.5)';
export const DEFAULT_HIGHLIGHT_BORDER_COLOR = '#ff0000';
+export const DEFAULT_INFO_BOX_HEIGHT = 200;
export const DEFAULT_INFO_BOX_WIDTH = '20rem';
+export const DEFAULT_INFO_BOX_MARGIN = 30;
export const DEFAULT_Z_INDEX = 9999;
export const HIGHLIGHT_Z_INDEX_OFFSET = 1;
export const INFO_BOX_Z_INDEX_OFFSET = 2;
@@ -15,13 +17,38 @@ export const DEFAULT_LABELS: Required = {
done: '완료',
};
-export function getLabels(options?: Options): Required {
+export function getLabels(options?: Options, step?: Step): Required {
return {
...DEFAULT_LABELS,
...options?.labels,
+ ...step?.options?.labels,
};
}
export function getBaseZIndex(options?: Options): number {
return options?.zIndex ?? DEFAULT_Z_INDEX;
}
+
+export function getInfoBoxHeight(options?: Options, step?: Step): number {
+ return step?.options?.infoBoxHeight ?? options?.infoBoxHeight ?? DEFAULT_INFO_BOX_HEIGHT;
+}
+
+export function getInfoBoxWidth(options?: Options, step?: Step): StyleValue {
+ return step?.options?.infoBoxWidth ?? options?.infoBoxWidth ?? DEFAULT_INFO_BOX_WIDTH;
+}
+
+export function getInfoBoxMargin(options?: Options, step?: Step): number {
+ return step?.options?.infoBoxMargin ?? options?.infoBoxMargin ?? DEFAULT_INFO_BOX_MARGIN;
+}
+
+export function getHighlightBorderColor(options?: Options, step?: Step): string {
+ return step?.options?.highlightBorderColor ?? options?.highlightBorderColor ?? DEFAULT_HIGHLIGHT_BORDER_COLOR;
+}
+
+export function getHighlightBorderRadius(
+ options?: Options,
+ step?: Step,
+ fallbackBorderRadius?: StyleValue
+): StyleValue | undefined {
+ return step?.options?.highlightBorderRadius ?? options?.highlightBorderRadius ?? fallbackBorderRadius;
+}
diff --git a/packages/main/src/core/tutorial.ts b/packages/main/src/core/tutorial.ts
index f8b6406..ec988f4 100644
--- a/packages/main/src/core/tutorial.ts
+++ b/packages/main/src/core/tutorial.ts
@@ -41,6 +41,12 @@ const getState = (): TutorialProgressState => {
? {
...currentStep,
targetIds: [...currentStep.targetIds],
+ options: currentStep.options
+ ? {
+ ...currentStep.options,
+ labels: currentStep.options.labels ? { ...currentStep.options.labels } : undefined,
+ }
+ : undefined,
}
: null,
};
diff --git a/packages/main/src/core/types.ts b/packages/main/src/core/types.ts
index 987a85b..4244dfa 100644
--- a/packages/main/src/core/types.ts
+++ b/packages/main/src/core/types.ts
@@ -1,21 +1,32 @@
export type StyleValue = number | string;
+export type InfoBoxAlignment = 'center' | 'left' | 'right';
+
+export interface Labels {
+ prev?: string;
+ next?: string;
+ skip?: string;
+ done?: string;
+}
+
+export interface StepOptions {
+ infoBoxHeight?: number;
+ infoBoxWidth?: StyleValue;
+ infoBoxMargin?: number;
+ highlightBorderColor?: string;
+ highlightBorderRadius?: StyleValue;
+ labels?: Labels;
+}
export interface Step {
targetIds: string[];
content?: string;
title?: string;
- infoBoxAlignment?: 'center' | 'left' | 'right';
+ infoBoxAlignment?: InfoBoxAlignment;
+ options?: StepOptions;
onPrevStep?: () => void;
onNextStep?: () => void;
}
-export interface Labels {
- prev?: string;
- next?: string;
- skip?: string;
- done?: string;
-}
-
export interface Options {
highLightPadding?: number;
infoBoxHeight?: number;
diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts
index 76cb354..6d9d8c2 100644
--- a/packages/main/src/index.ts
+++ b/packages/main/src/index.ts
@@ -2,5 +2,16 @@ import { tutorial } from './core/tutorial';
export { TutorialOverlay } from './components/tutorial-overlay';
export { tutorial };
+export type {
+ InfoBoxAlignment,
+ Labels,
+ Options,
+ Step,
+ StepOptions,
+ Tutorial,
+ TutorialProgressState,
+ TutorialResult,
+ TutorialResultReason,
+} from './core/types';
export default tutorial;
diff --git a/packages/main/test/content.test.tsx b/packages/main/test/content.test.tsx
index 359f8b3..723a8b1 100644
--- a/packages/main/test/content.test.tsx
+++ b/packages/main/test/content.test.tsx
@@ -14,6 +14,50 @@ function renderOverlay() {
}
describe('Content', () => {
+ test('prefers step labels over global labels and falls back to global or built-in labels', () => {
+ renderOverlay();
+
+ act(() => {
+ tutorial.open({
+ steps: [
+ {
+ title: 'Step 1',
+ content: 'Step 1 content',
+ targetIds: ['first-target'],
+ options: {
+ labels: {
+ skip: 'Leave',
+ },
+ },
+ },
+ {
+ title: 'Step 2',
+ content: 'Step 2 content',
+ targetIds: ['second-target'],
+ options: {
+ labels: {
+ done: 'Ship it',
+ },
+ },
+ },
+ ],
+ options: {
+ labels: {
+ next: 'Continue',
+ },
+ },
+ });
+ });
+
+ expect(screen.getByRole('button', { name: 'Leave' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
+
+ expect(screen.getByRole('button', { name: '이전' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Ship it' })).toBeInTheDocument();
+ });
+
test('reads built-in button labels from options.labels', () => {
renderOverlay();
diff --git a/packages/main/test/tutorial-overlay.test.tsx b/packages/main/test/tutorial-overlay.test.tsx
index b0c7d38..a953106 100644
--- a/packages/main/test/tutorial-overlay.test.tsx
+++ b/packages/main/test/tutorial-overlay.test.tsx
@@ -396,4 +396,54 @@ describe('TutorialOverlay', () => {
zIndex: '4322',
});
});
+
+ test('prefers step-level highlight and info box overrides over global options', () => {
+ renderOverlay();
+ mockTargetRect('first-target', { left: 200, top: 550, width: 80, height: 40 });
+
+ act(() => {
+ tutorial.open({
+ steps: [
+ {
+ title: 'Step 1',
+ content: 'Step 1 content',
+ targetIds: ['first-target'],
+ infoBoxAlignment: 'left',
+ options: {
+ infoBoxHeight: 260,
+ infoBoxWidth: '30rem',
+ infoBoxMargin: 10,
+ highlightBorderColor: 'rgb(255, 0, 128)',
+ highlightBorderRadius: 18,
+ },
+ },
+ ],
+ options: {
+ overlayColor: 'rgba(12, 34, 56, 0.7)',
+ infoBoxHeight: 200,
+ infoBoxWidth: '18rem',
+ infoBoxMargin: 30,
+ highlightBorderColor: 'rgb(0, 255, 136)',
+ highlightBorderRadius: 12,
+ zIndex: 4321,
+ },
+ });
+ });
+
+ expect(screen.getByTestId('tutorial-overlay-backdrop')).toHaveStyle({
+ backgroundColor: 'rgba(12, 34, 56, 0.7)',
+ zIndex: '4321',
+ });
+ expect(screen.getByRole('dialog', { name: 'Step 1' })).toHaveStyle({
+ width: '30rem',
+ height: '260px',
+ top: '272px',
+ left: '192px',
+ });
+ expect(screen.getByTestId('tutorial-overlay-highlight-first-target')).toHaveStyle({
+ borderColor: 'rgb(255, 0, 128)',
+ borderRadius: '18px',
+ zIndex: '4322',
+ });
+ });
});