diff --git a/docs-src/src/content/docs/guides/usage.md b/docs-src/src/content/docs/guides/usage.md
index 43c18852b..9ea3effb3 100644
--- a/docs-src/src/content/docs/guides/usage.md
+++ b/docs-src/src/content/docs/guides/usage.md
@@ -310,7 +310,12 @@ function will be called in the `before-show` phase.
modal overlay opening. It can be either a number or an object with properties
`{ topLeft, bottomLeft, bottomRight, topRight }`
- `floatingUIOptions`: Extra options to pass to
- [Floating UI](https://floating-ui.com/docs/getting-started)
+ [Floating UI](https://floating-ui.com/docs/getting-started). This includes
+ `strategy`, which sets the CSS `position` of the step element and defaults to
+ `'absolute'`. It can be set per-step or on `defaultStepOptions`. See
+ [Floating UI's `strategy` documentation](https://floating-ui.com/docs/computePosition#strategy)
+ for when `'fixed'` is the better choice. Note that steps without an `attachTo`
+ element are always centered with `position: fixed` and ignore `strategy`.
- `showOn`: A function that, when it returns true, will show the step. If it
returns false, the step will be skipped.
- `skipMissingElement`: A boolean. When true, a step whose `attachTo.element`
diff --git a/docs-src/src/content/docs/recipes/cookbook.md b/docs-src/src/content/docs/recipes/cookbook.md
index b43c646a4..b4f80bd33 100644
--- a/docs-src/src/content/docs/recipes/cookbook.md
+++ b/docs-src/src/content/docs/recipes/cookbook.md
@@ -54,6 +54,51 @@ const tour = new Shepherd.Tour({
});
```
+### Positioning strategy
+
+Steps are positioned with `position: absolute` by default. Shepherd repositions
+them through Floating UI's `autoUpdate`, so the default already keeps a step
+locked to its target while the page — or any scrolling ancestor of the target —
+scrolls.
+
+If you need the step element to be `position: fixed` instead, set the Floating
+UI `strategy`. Floating UI recommends this when the target itself is
+`position: fixed`, or to escape a clipping ancestor; see
+[its `strategy` documentation](https://floating-ui.com/docs/computePosition#strategy)
+for the trade-offs.
+
+For example:
+
+```js
+const tour = new Shepherd.Tour({
+ steps: [
+ {
+ ...
+ floatingUIOptions: {
+ strategy: 'fixed'
+ }
+ ...
+ }
+ ]
+});
+```
+
+You can also set this once for every step via `defaultStepOptions`:
+
+```js
+const tour = new Shepherd.Tour({
+ defaultStepOptions: {
+ floatingUIOptions: {
+ strategy: 'fixed'
+ }
+ }
+});
+```
+
+Centered steps are always positioned in the viewport with `position: fixed`, so
+`strategy` has no effect on them. A step is centered when it has no `attachTo`
+at all, or when its `attachTo` is missing either `element` or `on`.
+
### Progress Indicator
Using the already exposed API, you could add a progress indicator of your choosing
diff --git a/shepherd.js/src/step.ts b/shepherd.js/src/step.ts
index c8804a265..e88fa0ef4 100644
--- a/shepherd.js/src/step.ts
+++ b/shepherd.js/src/step.ts
@@ -158,6 +158,12 @@ export interface StepOptions {
/**
* Extra [options to pass to FloatingUI]{@link https://floating-ui.com/docs/tutorial/}
+ *
+ * This includes `strategy`, the CSS `position` used for the step element,
+ * which defaults to `'absolute'`. Centered steps are always `position: fixed`
+ * and ignore `strategy` -- a step counts as centered when it has no
+ * `attachTo` at all, or when its `attachTo` is missing either `element` or
+ * `on`.
*/
floatingUIOptions?: ComputePositionConfig;
diff --git a/shepherd.js/src/utils/floating-ui.ts b/shepherd.js/src/utils/floating-ui.ts
index acec7b78f..3776e0814 100644
--- a/shepherd.js/src/utils/floating-ui.ts
+++ b/shepherd.js/src/utils/floating-ui.ts
@@ -11,7 +11,8 @@ import {
type ComputePositionConfig,
type MiddlewareData,
type Placement,
- type Alignment
+ type Alignment,
+ type Strategy
} from '@floating-ui/dom';
import type { Step, StepOptions, StepOptionsAttachTo } from '../step.ts';
import { isHTMLElement } from './type-check.ts';
@@ -116,11 +117,13 @@ function floatingUIposition(step: Step, shouldCenter: boolean) {
return ({
x,
y,
+ strategy,
placement,
middlewareData
}: {
x: number;
y: number;
+ strategy: Strategy;
placement: Placement;
middlewareData: MiddlewareData;
}) => {
@@ -129,6 +132,15 @@ function floatingUIposition(step: Step, shouldCenter: boolean) {
}
if (shouldCenter) {
+ // `position: fixed` is intentional here and must NOT follow `strategy`.
+ // Centering relies on `left`/`top: 50%` plus a `translate(-50%, -50%)`,
+ // and those percentages have to resolve against the viewport. Under the
+ // default `absolute` strategy they would resolve against the document
+ // instead, placing the step at 50% of the *page* height so it scrolls
+ // off screen. A step centers when it has no `attachTo`, or when its
+ // `attachTo` is missing either `element` or `on` (see `shouldCenterStep`);
+ // all of those are modal dialogs, so viewport centering is the correct
+ // behavior regardless of `strategy`.
Object.assign(step.el.style, {
position: 'fixed',
left: '50%',
@@ -137,7 +149,7 @@ function floatingUIposition(step: Step, shouldCenter: boolean) {
});
} else {
Object.assign(step.el.style, {
- position: 'absolute',
+ position: strategy,
left: `${x}px`,
top: `${y}px`
});
diff --git a/shepherd.js/test/cypress/examples/positioning-strategy.html b/shepherd.js/test/cypress/examples/positioning-strategy.html
new file mode 100644
index 000000000..f86c697a3
--- /dev/null
+++ b/shepherd.js/test/cypress/examples/positioning-strategy.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
Page target
+
+
+
+
Overflow target
+
+
+
+
diff --git a/shepherd.js/test/cypress/integration/positioning-strategy.cy.js b/shepherd.js/test/cypress/integration/positioning-strategy.cy.js
new file mode 100644
index 000000000..94d717ac4
--- /dev/null
+++ b/shepherd.js/test/cypress/integration/positioning-strategy.cy.js
@@ -0,0 +1,150 @@
+import setupTour from '../utils/setup-tour';
+
+// End-to-end guard for #3269. Unit tests run in happy-dom, which has no layout
+// engine, so this is the only place where a step that drifts away from its
+// target while scrolling actually fails a test.
+describe('positioning strategy', () => {
+ let Shepherd;
+
+ beforeEach(() => {
+ Shepherd = null;
+
+ cy.visit('/test/cypress/examples/positioning-strategy', {
+ onLoad(contentWindow) {
+ if (contentWindow.Shepherd) {
+ return (Shepherd = contentWindow.Shepherd);
+ }
+ }
+ });
+ });
+
+ /**
+ * Vector from the target to the step, in viewport coordinates. If the step
+ * tracks its target, this vector is identical at every scroll offset.
+ */
+ const offsetFromTarget = (targetSelector, stepId) => {
+ return cy.get(targetSelector).then(($target) => {
+ return cy.get(`[data-shepherd-step-id="${stepId}"]`).then(($step) => {
+ const target = $target[0].getBoundingClientRect();
+ const step = $step[0].getBoundingClientRect();
+
+ return {
+ dx: Math.round(step.left - target.left),
+ dy: Math.round(step.top - target.top)
+ };
+ });
+ });
+ };
+
+ const startTour = (floatingUIOptions) => {
+ const tour = setupTour(Shepherd, { scrollTo: false }, () => [
+ {
+ attachTo: { element: '.page-target', on: 'bottom' },
+ id: 'strategy',
+ title: 'Strategy step',
+ text: 'positioned against a target the page scrolls past',
+ floatingUIOptions
+ }
+ ]);
+
+ tour.start();
+ cy.wait(250);
+
+ return tour;
+ };
+
+ it('keeps a `fixed` strategy step locked to its target while the page scrolls', () => {
+ startTour({ strategy: 'fixed' });
+
+ cy.scrollTo(0, 700);
+ cy.wait(250);
+
+ cy.get('[data-shepherd-step-id="strategy"]').should(
+ 'have.css',
+ 'position',
+ 'fixed'
+ );
+
+ let before;
+
+ offsetFromTarget('.page-target', 'strategy')
+ .then((offset) => {
+ before = offset;
+
+ cy.scrollTo(0, 800);
+ cy.wait(250);
+
+ return offsetFromTarget('.page-target', 'strategy');
+ })
+ .then((after) => {
+ // Before the fix, the step element was hardcoded to
+ // `position: absolute` while `computePosition` returned
+ // viewport-relative coordinates, so `after.dy` was `before.dy - 100` —
+ // exactly the scroll delta.
+ expect(after).to.deep.equal(before);
+ });
+ });
+
+ it('keeps a default strategy step locked to its target while the page scrolls', () => {
+ startTour(undefined);
+
+ cy.scrollTo(0, 700);
+ cy.wait(250);
+
+ cy.get('[data-shepherd-step-id="strategy"]').should(
+ 'have.css',
+ 'position',
+ 'absolute'
+ );
+
+ let before;
+
+ offsetFromTarget('.page-target', 'strategy')
+ .then((offset) => {
+ before = offset;
+
+ cy.scrollTo(0, 800);
+ cy.wait(250);
+
+ return offsetFromTarget('.page-target', 'strategy');
+ })
+ .then((after) => {
+ expect(after).to.deep.equal(before);
+ });
+ });
+
+ it('tracks a target inside a scrolling container under the default strategy', () => {
+ const tour = setupTour(Shepherd, { scrollTo: false }, () => [
+ {
+ attachTo: { element: '.overflow-target', on: 'bottom' },
+ id: 'overflow',
+ title: 'Overflow step',
+ text: 'positioned against a target inside an overflow container'
+ }
+ ]);
+
+ tour.start();
+
+ cy.get('.overflow-container').scrollTo(0, 500);
+ cy.wait(250);
+
+ let before;
+
+ offsetFromTarget('.overflow-target', 'overflow')
+ .then((offset) => {
+ before = offset;
+
+ cy.get('.overflow-container').scrollTo(0, 600);
+ cy.wait(250);
+
+ return offsetFromTarget('.overflow-target', 'overflow');
+ })
+ .then((after) => {
+ // `autoUpdate` recomputes on ancestor scroll, so the default
+ // `absolute` strategy already follows a target inside an `overflow`
+ // container. This is why the docs do not recommend `fixed` for that
+ // case.
+ expect(after).to.deep.equal(before);
+ });
+ });
+});
diff --git a/shepherd.js/test/unit/tour.spec.js b/shepherd.js/test/unit/tour.spec.js
index 4babff03f..94bf75a1d 100644
--- a/shepherd.js/test/unit/tour.spec.js
+++ b/shepherd.js/test/unit/tour.spec.js
@@ -804,6 +804,91 @@ describe('Tour | Top-Level Class', function () {
);
expect(step3PlacementMiddleware.options.alignment).toBe('end');
});
+
+ describe('strategy', () => {
+ let target;
+
+ beforeEach(() => {
+ target = document.createElement('div');
+ target.classList.add('strategy-test');
+ document.body.appendChild(target);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(target);
+ });
+
+ it('writes the default `absolute` strategy to an attached step', async () => {
+ instance = new Shepherd.Tour();
+
+ const step = instance.addStep({
+ id: 'test',
+ title: 'This is a test step for our tour',
+ attachTo: { element: '.strategy-test', on: 'top' }
+ });
+
+ instance.start();
+
+ await vi.waitFor(() => expect(step.el.style.position).toBe('absolute'));
+ });
+
+ it('honors `strategy: fixed` from step floatingUIOptions', async () => {
+ instance = new Shepherd.Tour();
+
+ const step = instance.addStep({
+ id: 'test',
+ title: 'This is a test step for our tour',
+ attachTo: { element: '.strategy-test', on: 'top' },
+ floatingUIOptions: { strategy: 'fixed' }
+ });
+
+ instance.start();
+
+ await vi.waitFor(() => expect(step.el.style.position).toBe('fixed'));
+ });
+
+ it('honors `strategy: fixed` from defaultStepOptions floatingUIOptions', async () => {
+ instance = new Shepherd.Tour({
+ defaultStepOptions: {
+ floatingUIOptions: { strategy: 'fixed' }
+ }
+ });
+
+ const step = instance.addStep({
+ id: 'test',
+ title: 'This is a test step for our tour',
+ attachTo: { element: '.strategy-test', on: 'top' }
+ });
+
+ instance.start();
+
+ await vi.waitFor(() => expect(step.el.style.position).toBe('fixed'));
+ });
+
+ // Centered steps are modal dialogs positioned with `left`/`top: 50%` and
+ // a `translate(-50%, -50%)`. Those percentages must resolve against the
+ // viewport, so the centered branch stays `fixed` no matter what strategy
+ // is configured. This test locks that in deliberately.
+ it.each([undefined, 'absolute', 'fixed'])(
+ 'always centers with `position: fixed` when strategy is %s',
+ async (strategy) => {
+ instance = new Shepherd.Tour();
+
+ const step = instance.addStep({
+ id: 'test',
+ title: 'This is a test step for our tour',
+ floatingUIOptions: strategy ? { strategy } : {}
+ });
+
+ instance.start();
+
+ await vi.waitFor(() => expect(step.el.style.position).toBe('fixed'));
+
+ expect(step.el.style.left).toBe('50%');
+ expect(step.el.style.top).toBe('50%');
+ }
+ );
+ });
});
describe('shepherdModalOverlayContainer', function () {
diff --git a/shepherd.js/test/unit/utils/floating-ui-position.spec.js b/shepherd.js/test/unit/utils/floating-ui-position.spec.js
new file mode 100644
index 000000000..9e785bbbb
--- /dev/null
+++ b/shepherd.js/test/unit/utils/floating-ui-position.spec.js
@@ -0,0 +1,186 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import ResizeObserver from 'resize-observer-polyfill';
+
+// happy-dom has no layout engine: every `getBoundingClientRect()` is 0x0 at the
+// origin, so a real `computePosition` call always resolves `{ x: 0, y: 0 }` and
+// no assertion about coordinates can fail. These tests therefore stub the
+// Floating UI boundary and assert the one thing Shepherd actually owns: what it
+// writes to `step.el.style` for a given resolved position payload.
+const floatingUI = vi.hoisted(() => ({
+ // Overwritten per test to control exactly what the positioning callback gets.
+ position: {
+ x: 0,
+ y: 0,
+ strategy: 'absolute',
+ placement: 'top',
+ middlewareData: {}
+ }
+}));
+
+vi.mock('@floating-ui/dom', async (importOriginal) => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ computePosition: vi.fn(() => Promise.resolve({ ...floatingUI.position })),
+ // Run the position callback once, synchronously, rather than wiring up the
+ // scroll/resize observers that happy-dom cannot drive.
+ autoUpdate: vi.fn((_target, _element, update) => {
+ update();
+ return () => {};
+ })
+ };
+});
+
+const Shepherd = (await import('../../../src/shepherd')).default;
+const { destroyTooltip, setupTooltip } =
+ await import('../../../src/utils/floating-ui');
+
+window.Shepherd = Shepherd;
+window.ResizeObserver = ResizeObserver;
+
+describe('utils/floating-ui | position writing', function () {
+ let instance, target;
+
+ beforeEach(() => {
+ target = document.createElement('div');
+ target.classList.add('position-test');
+ document.body.appendChild(target);
+ });
+
+ afterEach(() => {
+ instance?.complete();
+ target.remove();
+ window.scrollTo(0, 0);
+ floatingUI.position = {
+ x: 0,
+ y: 0,
+ strategy: 'absolute',
+ placement: 'top',
+ middlewareData: {}
+ };
+ });
+
+ /**
+ * Re-runs positioning with a caller-supplied Floating UI payload and waits
+ * for it to land on the element. The payload is swapped in *after*
+ * `tour.start()` has already positioned the step once, so an assertion on
+ * these values can only be satisfied by this `setupTooltip` call.
+ */
+ async function positionWith(step, position) {
+ floatingUI.position = { ...floatingUI.position, ...position };
+
+ setupTooltip(step);
+
+ await vi.waitFor(() =>
+ expect(step.el.dataset['popperPlacement']).toBe(
+ floatingUI.position.placement
+ )
+ );
+ }
+
+ it('writes the resolved coordinates to the step element', async () => {
+ instance = new Shepherd.Tour();
+
+ const step = instance.addStep({
+ id: 'test',
+ attachTo: { element: '.position-test', on: 'top' }
+ });
+
+ instance.start();
+
+ await positionWith(step, { x: 13, y: 42, placement: 'bottom' });
+
+ // `x` is horizontal and `y` is vertical. Transposing them puts every step
+ // on the wrong side of its target.
+ expect(step.el.style.left).toBe('13px');
+ expect(step.el.style.top).toBe('42px');
+
+ destroyTooltip(step);
+ });
+
+ it('writes coordinates unmodified while the page is scrolled', async () => {
+ instance = new Shepherd.Tour();
+
+ const step = instance.addStep({
+ id: 'test',
+ attachTo: { element: '.position-test', on: 'top' },
+ floatingUIOptions: { strategy: 'fixed' }
+ });
+
+ instance.start();
+
+ window.scrollTo(120, 340);
+
+ await positionWith(step, {
+ x: 13,
+ y: 42,
+ strategy: 'fixed',
+ placement: 'bottom'
+ });
+
+ // This is the shape of #3269: Floating UI's payload is already in the
+ // coordinate space its own `strategy` implies, so adding (or subtracting)
+ // the scroll offset here re-separates the step from its target by exactly
+ // the scroll delta.
+ expect(step.el.style.left).toBe('13px');
+ expect(step.el.style.top).toBe('42px');
+ expect(step.el.style.position).toBe('fixed');
+
+ destroyTooltip(step);
+ });
+
+ it('takes the strategy from the resolved payload rather than the step options', async () => {
+ instance = new Shepherd.Tour();
+
+ // No `floatingUIOptions` on the step at all. The CSS `position` still has
+ // to follow what `computePosition` reports it used, because that payload —
+ // not the raw user option — is what the returned coordinates are relative
+ // to. Reading `step.options.floatingUIOptions.strategy` instead would write
+ // `absolute` here and reintroduce the coordinate-space mismatch.
+ const step = instance.addStep({
+ id: 'test',
+ attachTo: { element: '.position-test', on: 'top' }
+ });
+
+ instance.start();
+
+ expect(step.options.floatingUIOptions?.strategy).toBeUndefined();
+
+ await positionWith(step, {
+ x: 13,
+ y: 42,
+ strategy: 'fixed',
+ placement: 'bottom'
+ });
+
+ expect(step.el.style.position).toBe('fixed');
+
+ destroyTooltip(step);
+ });
+
+ it('keeps centered steps viewport-centered whatever the payload says', async () => {
+ instance = new Shepherd.Tour();
+
+ // No `attachTo`, so this step is centered. Centering uses `left`/`top: 50%`
+ // with a `translate(-50%, -50%)`, and those percentages have to resolve
+ // against the viewport, so this branch must not follow the strategy.
+ const step = instance.addStep({ id: 'test' });
+
+ instance.start();
+
+ await positionWith(step, {
+ x: 13,
+ y: 42,
+ strategy: 'absolute',
+ placement: 'bottom'
+ });
+
+ expect(step.el.style.position).toBe('fixed');
+ expect(step.el.style.left).toBe('50%');
+ expect(step.el.style.top).toBe('50%');
+ expect(step.el.style.transform).toBe('translate(-50%, -50%)');
+
+ destroyTooltip(step);
+ });
+});