Horizontal Handoff

A section that pins and spends your scroll moving its panels sideways.

free vanilla 17.4KB scrollstickyhorizontalsection
Scroll inside the frame.

Options

Option Type Default Notes
selector string ":scope > *" Which descendants are panels, relative to the target. Defaults to its direct children.
pace number 1 Vertical scroll spent per pixel of horizontal travel. 1 is one-to-one; 2 asks for twice the scrolling.

Install

  1. Copy horizontal-handoff.js and horizontal-handoff.css into your project. That is the whole component — it imports nothing.
  2. Include tokens.css once, anywhere on the page. Every component reads its colour, spacing and motion from it.
  3. Link the two files, then call createHorizontalHandoff on a <section>.
<link rel="stylesheet" href="tokens.css" />
<link rel="stylesheet" href="horizontal-handoff.css" />

<script defer src="horizontal-handoff.js"></script>
const instance = VC.createHorizontalHandoff(
  document.querySelector('#target'),
  {}
);

// Later, on teardown:
instance.destroy();

The bundle is a classic script, so it works over file:// with no server and no build step, and puts createHorizontalHandoff on window.VC. If you would rather have the ES module, it is packages/registry/horizontal-handoff/ in the repo — same source, four files once _utils/ comes with it.

horizontal-handoff.js

25.3KB · 772 lines

Raw
View source
/**
 * GENERATED — run pnpm build:components — do not edit.
 *
 * Source of truth: packages/registry/horizontal-handoff/horizontal-handoff.js
 *
 * Flattened from ES modules to one classic script by scripts/lab-bundle.js —
 * byte for byte the build the Noir Portfolio template ships, so the preview
 * you are watching is the file the copy button hands you.
 *
 * Exposes: window.VC.createHorizontalHandoff
 */
(function () {
  'use strict';

  /* ── packages/registry/_utils/dom.js ───────────────────────────────── */
  /**
   * DOM construction and listener helpers.
   *
   * Zero dependencies. Every import specifier in this package must start with
   * '.' or '/' — see scripts/check-isolation.js.
   */
  
  const SVG_NS = 'http://www.w3.org/2000/svg';
  
  /**
   * @typedef {string | number | boolean | null | undefined} AttrValue
   */
  
  /**
   * Apply attributes to an element.
   *
   * - `class` and `className` both set the class attribute.
   * - `style` takes a CSS string.
   * - `data-*` and `aria-*` pass straight through.
   * - `null`, `undefined` and `false` skip the attribute entirely, so callers can
   *   pass conditionals inline.
   * - `true` sets a valueless attribute.
   *
   * @param {Element} node
   * @param {Record<string, AttrValue>} attrs
   * @returns {void}
   */
  function applyAttrs(node, attrs) {
    for (const [key, value] of Object.entries(attrs)) {
      if (value === null || value === undefined || value === false) continue;
      const name = key === 'className' ? 'class' : key;
      node.setAttribute(name, value === true ? '' : String(value));
    }
  }
  
  /**
   * Append children, coercing strings to text nodes.
   *
   * @param {Element} node
   * @param {Array<Node | string>} children
   * @returns {void}
   */
  function appendChildren(node, children) {
    for (const child of children) {
      node.append(child);
    }
  }
  
  /**
   * Create an HTML element.
   *
   * @template {keyof HTMLElementTagNameMap} K
   * @param {K} tag
   * @param {Record<string, AttrValue>} [attrs]
   * @param {Array<Node | string>} [children]
   * @returns {HTMLElementTagNameMap[K]}
   *
   * @example
   * const card = el('article', { class: 'vc-card', 'aria-live': 'polite' }, [
   *   el('h3', null, ['Title'])
   * ]);
   */
  function el(tag, attrs, children) {
    const node = document.createElement(tag);
    if (attrs) applyAttrs(node, attrs);
    if (children) appendChildren(node, children);
    return node;
  }
  
  /**
   * Create an SVG element. Needed because `document.createElement` produces an
   * HTML element that renders as nothing inside an `<svg>`.
   *
   * @template {keyof SVGElementTagNameMap} K
   * @param {K} tag
   * @param {Record<string, AttrValue>} [attrs]
   * @param {Array<Node | string>} [children]
   * @returns {SVGElementTagNameMap[K]}
   */
  function svgEl(tag, attrs, children) {
    const node = /** @type {SVGElementTagNameMap[K]} */ (
      document.createElementNS(SVG_NS, tag)
    );
    if (attrs) applyAttrs(node, attrs);
    if (children) appendChildren(node, children);
    return node;
  }
  
  /**
   * Bind a listener and get its own teardown back.
   *
   * Returning the cleanup rather than exposing `off()` is what makes `destroy()`
   * auditable: a component collects these in an array and the array length is
   * the number of things it has to undo.
   *
   * The returned function is idempotent — calling it twice is harmless.
   *
   * @template {Event} [E=Event]
   * @param {EventTarget} target
   * @param {string} type
   * @param {(event: E) => void} handler
   * @param {AddEventListenerOptions | boolean} [options]
   * @returns {() => void} cleanup
   *
   * @example
   * const cleanups = [
   *   on(window, 'pointermove', onMove, { passive: true }),
   *   on(el, 'pointerleave', onLeave)
   * ];
   * // destroy: cleanups.forEach((off) => off());
   */
  function on(target, type, handler, options) {
    const listener = /** @type {EventListener} */ (/** @type {unknown} */ (handler));
    target.addEventListener(type, listener, options);
  
    let removed = false;
    return () => {
      if (removed) return;
      removed = true;
      target.removeEventListener(type, listener, options);
    };
  }

  /* ── packages/registry/_utils/motion.js ────────────────────────────── */
  /**
   * prefers-reduced-motion, as a live value.
   *
   * Reading the media query once at module load is wrong: the user can change the
   * setting while the page is open, and a component that cached the old answer
   * keeps animating. Every animated component gates on `prefersReducedMotion()`
   * and subscribes to `onReducedMotionChange()`.
   *
   * The contract: reduced motion means static but complete. Never blank.
   *
   * Zero dependencies.
   */
  
  const QUERY = '(prefers-reduced-motion: reduce)';
  
  /** @type {MediaQueryList | null} */
  let query = null;
  
  /**
   * Lazily resolve the MediaQueryList. Returns null where matchMedia is missing,
   * which is the SSR pass and some test environments.
   *
   * @returns {MediaQueryList | null}
   */
  function mediaQuery() {
    if (query) return query;
    if (typeof matchMedia !== 'function') return null;
    query = matchMedia(QUERY);
    return query;
  }
  
  /**
   * Whether the user has asked for reduced motion, right now.
   *
   * Defaults to `false` when the query is unavailable — animation is the design
   * intent, and a server render has no user preference to read.
   *
   * @returns {boolean}
   */
  function prefersReducedMotion() {
    return mediaQuery()?.matches ?? false;
  }
  
  /**
   * Subscribe to changes in the preference.
   *
   * @param {(reduced: boolean) => void} handler
   * @returns {() => void} unsubscribe
   *
   * @example
   * let reduced = prefersReducedMotion();
   * const stopWatching = onReducedMotionChange((next) => {
   *   reduced = next;
   *   if (reduced) settleToFinalState();
   * });
   * // destroy: stopWatching();
   */
  function onReducedMotionChange(handler) {
    const media = mediaQuery();
    if (!media) return () => {};
  
    /** @param {MediaQueryListEvent} event */
    const listener = (event) => handler(event.matches);
    media.addEventListener('change', listener);
  
    let removed = false;
    return () => {
      if (removed) return;
      removed = true;
      media.removeEventListener('change', listener);
    };
  }

  /* ── packages/registry/_utils/raf.js ───────────────────────────────── */
  /**
   * One requestAnimationFrame loop for the whole page.
   *
   * Twelve components each starting a private rAF means twelve callbacks the
   * browser schedules separately, and twelve places a leak can hide. Components
   * register here and get a deregister function back; the loop starts on the
   * first subscriber and stops on the last.
   *
   * Zero dependencies.
   */
  
  /**
   * @callback FrameCallback
   * @param {number} time Timestamp from rAF, in ms since page load.
   * @param {number} delta Ms since the previous frame. 0 on the first frame.
   * @returns {void}
   */
  
  /** @type {Set<FrameCallback>} */
  const callbacks = new Set();
  
  /** @type {number} Active rAF handle, or 0 when stopped. */
  let handle = 0;
  
  /** @type {number} Timestamp of the previous frame, or 0 before the first. */
  let previous = 0;
  
  /**
   * @param {number} time
   * @returns {void}
   */
  function tick(time) {
    handle = requestAnimationFrame(tick);
  
    const delta = previous === 0 ? 0 : time - previous;
    previous = time;
  
    // Iterate a copy: a callback may deregister itself, or another, mid-frame.
    for (const callback of [...callbacks]) {
      callback(time, delta);
    }
  }
  
  /**
   * @returns {void}
   */
  function start() {
    if (handle !== 0) return;
    if (typeof requestAnimationFrame !== 'function') return;
    previous = 0;
    handle = requestAnimationFrame(tick);
  }
  
  /**
   * @returns {void}
   */
  function stop() {
    if (handle === 0) return;
    cancelAnimationFrame(handle);
    handle = 0;
    previous = 0;
  }
  
  /**
   * Run a callback on every frame until the returned function is called.
   *
   * The returned function is idempotent, so a `destroy()` that runs twice is
   * harmless.
   *
   * @param {FrameCallback} callback
   * @returns {() => void} deregister
   *
   * @example
   * const stopFrame = onFrame((time, delta) => {
   *   x += velocity * delta;
   * });
   * // destroy: stopFrame();
   */
  function onFrame(callback) {
    callbacks.add(callback);
    start();
  
    let done = false;
    return () => {
      if (done) return;
      done = true;
      callbacks.delete(callback);
      if (callbacks.size === 0) stop();
    };
  }
  
  /**
   * Number of live subscribers. Exists so the lab harness and the lifecycle
   * checklist can assert that `destroy()` actually let go of the loop.
   *
   * @returns {number}
   */
  function frameSubscriberCount() {
    return callbacks.size;
  }

  /* ── packages/registry/horizontal-handoff/horizontal-handoff.js ────── */
  /**
   * Horizontal Handoff
   *
   * A section that pins while the page keeps scrolling, and spends that scroll
   * moving its panels sideways. Vertical scroll hands off to horizontal travel
   * and then hands back.
   *
   * Scroll-linked, never scroll-jacking. No wheel event is intercepted and no
   * scroll is ever cancelled: the page moves at exactly the speed the visitor
   * asked for, keeps its own scrollbar, its own momentum and its own keyboard
   * shortcuts, and all this component decides is where the panels sit as it goes.
   *
   * @see NOTES.md
   */
  
  
  
  
  
  const BASE = 'vc-horizontal-handoff';
  const PINNED = `${BASE}--pinned`;
  
  /** Extra px of daylight left around a focus ring scrolled back into view. */
  const FOCUS_CLEARANCE = 24;
  
  /**
   * Targets with a live instance, mapped to the child nodes they had before.
   * Mounting twice would wrap the first instance's scaffolding in a second
   * copy, so it fails loudly instead.
   *
   * @type {WeakMap<HTMLElement, ChildNode[]>}
   */
  const mounted = new WeakMap();
  
  /**
   * @typedef {object} HorizontalHandoffOptions
   * @property {string} [selector] Which descendants are panels, relative to the
   *   target. Defaults to its direct children.
   * @property {number} [pace] Vertical scroll spent per pixel of horizontal
   *   travel. 1 is one-to-one; 2 asks for twice the scrolling.
   */
  
  /**
   * @typedef {object} HorizontalHandoffInstance
   * @property {() => void} destroy
   */
  
  /**
   * Nearest ancestor that scrolls vertically, falling back to the document.
   *
   * Duplicated from card-stack rather than shared. Two components is not yet a
   * util — and a component in this registry is bought by copying its own files,
   * so the bar for adding to `_utils/` is a third caller, not a second.
   *
   * @param {HTMLElement} node
   * @returns {Element}
   */
  function scrollport(node) {
    /** @type {HTMLElement | null} */
    let current = node.parentElement;
  
    while (current) {
      const overflow = getComputedStyle(current).overflowY;
      const scrolls = overflow === 'auto' || overflow === 'scroll';
      if (scrolls && current.scrollHeight > current.clientHeight) return current;
      current = current.parentElement;
    }
  
    return document.scrollingElement ?? document.documentElement;
  }
  
  /**
   * @param {HTMLElement} target
   * @param {HorizontalHandoffOptions} [options]
   * @returns {HorizontalHandoffInstance}
   */
  function createHorizontalHandoff(target, options = {}) {
    if (mounted.has(target)) {
      throw new Error(
        'createHorizontalHandoff: this element already has an instance — destroy it first'
      );
    }
  
    const { selector = ':scope > *', pace = 1 } = options;
  
    /** @type {Array<() => void>} */
    const cleanups = [];
  
    const original = [...target.childNodes];
    mounted.set(target, original);
    target.classList.add(BASE);
  
    /** @type {HTMLElement[]} */
    const panels = [...target.querySelectorAll(selector)].filter(
      (node) => node instanceof HTMLElement
    );
  
    cleanups.push(() => {
      target.classList.remove(BASE);
      target.style.removeProperty('height');
      if (target.getAttribute('style') === '') target.removeAttribute('style');
      target.replaceChildren(...original);
      mounted.delete(target);
    });
  
    /* ── Nothing to hand off ───────────────────────────────────────────────
       No panels means no section. Left exactly as found. */
  
    if (panels.length === 0) {
      return {
        destroy() {
          while (cleanups.length > 0) cleanups.pop()?.();
        }
      };
    }
  
    /* ── DOM ───────────────────────────────────────────────────────────────
       Two wrappers the component owns, because it needs to pin one box and
       move another inside it, and a consumer should not have to know that.
  
       The scaffolding is built whether or not the handoff ends up running: the
       stylesheet leaves it as an ordinary horizontal scroller, so this same
       structure is the reduced-motion mode and the too-short-to-pin mode. Only
       the --pinned class switches it into a sticky one. */
  
    const viewport = el('div', { class: `${BASE}__viewport` });
    const track = el('div', { class: `${BASE}__track` });
  
    track.append(...panels);
    viewport.append(track);
    target.replaceChildren(viewport);
  
    /* ── State ─────────────────────────────────────────────────────────────── */
  
    let reduced = prefersReducedMotion();
    let pinned = false;
    let visible = true;
  
    /** How far the track has to travel, px. 0 means there is nothing to do. */
    let distance = 0;
  
    /** Vertical scroll the handoff occupies, px. */
    let runway = 0;
  
    /** Where the section sat on the previous frame. NaN forces a recompute. */
    let lastTop = Number.NaN;
  
    /** Last value written to the DOM. */
    let wroteX = Number.NaN;
  
    /** The box `position: sticky` pins against, found at measure time. */
    /** @type {Element | null} */
    let port = null;
  
    /** @type {(() => void) | null} Live rAF subscription. */
    let frameHandle = null;
  
    /* ── Measure ───────────────────────────────────────────────────────────── */
  
    /**
     * Work out how far there is to travel, and how much scroll to spend on it.
     *
     * Both numbers come from layout, not from anything the component has
     * written: `scrollWidth` and `offsetHeight` ignore transforms, so the track
     * can be measured mid-travel and still report where it would be at rest.
     *
     * @returns {void}
     */
    function measure() {
      if (reduced) {
        pinned = false;
        target.classList.remove(PINNED);
        target.style.removeProperty('height');
        return;
      }
  
      /* Pinned first, then measured. The viewport is only a screen tall once the
         class is on, and the section's height is derived from that — measuring
         before pinning would size the section against the wrong viewport. */
      target.classList.add(PINNED);
  
      const viewWidth = viewport.clientWidth;
      const viewHeight = viewport.offsetHeight;
  
      distance = Math.max(0, track.scrollWidth - viewWidth);
      runway = distance * Math.max(0, pace);
  
      /* Nothing wider than the viewport, so there is nothing to hand off — and a
         section with nothing to hand off must not pin. The scaffolding stays,
         because it is a perfectly good row of panels, but the section gives back
         the screenful of scrolling it would otherwise have taken to say nothing. */
      if (runway <= 0) {
        pinned = false;
        target.classList.remove(PINNED);
        target.style.removeProperty('height');
        return;
      }
  
      pinned = true;
      port = scrollport(target);
      target.style.height = `${viewHeight + runway}px`;
    }
  
    /**
     * Where the top of the scrollport sits, in viewport coordinates.
     *
     * Zero for the document, whose own box scrolls with the page and so would
     * cancel out the very movement being measured. For a nested scroller it is
     * the real edge the section pins against.
     *
     * @returns {number}
     */
    function portOffset() {
      if (
        !port ||
        port === document.scrollingElement ||
        port === document.documentElement
      ) {
        return 0;
      }
      const rect = port.getBoundingClientRect();
      return rect.top + port.clientTop;
    }
  
    /**
     * @param {number} x
     * @returns {void}
     */
    function write(x) {
      const next = Math.round(x * 100) / 100;
      if (next === wroteX) return;
      wroteX = next;
      track.style.setProperty('--vc-hh-x', `${next}px`);
    }
  
    /**
     * @returns {void}
     */
    function update() {
      if (runway <= 0) {
        write(0);
        return;
      }
  
      const top = target.getBoundingClientRect().top;
      const progress = Math.min(1, Math.max(0, (portOffset() - top) / runway));
      write(-progress * distance);
    }
  
    /* ── Frames ────────────────────────────────────────────────────────────
       Scroll wakes the loop and the loop gives the frame back as soon as the
       section stops moving, so a pinned section that nobody is scrolling past
       costs nothing. */
  
    /**
     * @returns {void}
     */
    function frame() {
      const now = target.getBoundingClientRect().top;
      if (now === lastTop) {
        sleep();
        return;
      }
  
      lastTop = now;
      update();
    }
  
    /**
     * @returns {void}
     */
    function wake() {
      if (frameHandle || !pinned || !visible) return;
      frameHandle = onFrame(frame);
    }
  
    /**
     * @returns {void}
     */
    function sleep() {
      if (!frameHandle) return;
      frameHandle();
      frameHandle = null;
    }
  
    cleanups.push(sleep);
  
    /**
     * Something other than scrolling changed the geometry. The section's own top
     * may be identical, so the next frame has to be told not to trust it.
     *
     * @returns {void}
     */
    function invalidate() {
      lastTop = Number.NaN;
      measure();
  
      if (pinned) {
        wake();
        return;
      }
  
      sleep();
      write(0);
    }
  
    /* ── Scroll ────────────────────────────────────────────────────────────
       Capture, on window: scroll events do not bubble, but they do run capture
       listeners on the way down, so this one hears a nested scroller too. */
  
    cleanups.push(
      on(window, 'scroll', wake, { passive: true, capture: true }),
      on(window, 'resize', invalidate, { passive: true }),
  
      /* Pinned, the transform is the only thing allowed to move the track. But
         `overflow: hidden` does not stop the browser scrolling a box to bring a
         focused descendant into view — it only stops the user — so focus landing
         off to the side silently adds a scroll offset on top of the transform,
         and the two then drift apart for the rest of the page's life. Putting it
         straight back is cheap, settles immediately, and keeps the invariant. */
      on(viewport, 'scroll', () => {
        if (pinned && viewport.scrollLeft !== 0) viewport.scrollLeft = 0;
      })
    );
  
    /* ── Off-screen ────────────────────────────────────────────────────────── */
  
    if (typeof IntersectionObserver === 'function') {
      const observer = new IntersectionObserver((entries) => {
        visible = entries.some((entry) => entry.isIntersecting);
        if (visible) invalidate();
        else sleep();
      });
      observer.observe(target);
      cleanups.push(() => observer.disconnect());
    }
  
    /* ── Reflow ────────────────────────────────────────────────────────────
       The viewport and the track are observed; the target deliberately is not.
       The target's height is the one thing this component writes, so observing
       it would mean a resize that triggers a measure that writes a height that
       triggers a resize — the loop Chrome reports as an undelivered-notification
       error. Neither observed box depends on that height. */
  
    if (typeof ResizeObserver === 'function') {
      const observer = new ResizeObserver(() => invalidate());
      observer.observe(viewport);
      observer.observe(track);
      cleanups.push(() => observer.disconnect());
    }
  
    /* ── Keyboard ──────────────────────────────────────────────────────────
       A panel translated off to the right is still in the DOM, still in the tab
       order, and still perfectly focusable. The browser's own scroll-into-view
       cannot help: the viewport is pinned and has nothing left to scroll, and
       the panel is where it is because of a transform, not a scroll offset.
  
       What does reveal it is scrolling the page — which is what moves the track
       — so focus landing on something off to the side converts the position it
       needs into the page scroll that produces it.
  
       Only in pinned mode. Unpinned, the viewport is an ordinary scroll
       container and the browser already does the right thing. */
  
    cleanups.push(
      on(target, 'focusin', (/** @type {FocusEvent} */ event) => {
        if (!pinned || runway <= 0) return;
  
        const node = event.target;
        if (!(node instanceof Element)) return;
  
        /* The browser may already have scrolled the clipped viewport to reveal
           this element. Undo that before measuring, or every position read below
           is taken against a layout that is about to be put back. */
        viewport.scrollLeft = 0;
  
        const view = viewport.getBoundingClientRect();
        const focus = node.getBoundingClientRect();
  
        /* Already on screen: leave the page where the visitor put it. */
        if (focus.left >= view.left && focus.right <= view.right) return;
  
        const panel = panels.find((candidate) => candidate.contains(node));
        if (!panel) return;
  
        /* Where the track has to be for this panel to start at the left edge,
           expressed as progress, then as the scroll that produces it. */
        const wanted = Math.min(1, Math.max(0, panel.offsetLeft / distance));
        const top = target.getBoundingClientRect().top;
  
        scrollport(target).scrollBy(0, top + wanted * runway - FOCUS_CLEARANCE);
      })
    );
  
    /* ── Reduced motion ────────────────────────────────────────────────────
       Static, but complete — and here that is better than complete. Unpinned,
       the viewport is an ordinary horizontal scroll container: every panel is
       reachable, the layout the designer drew is intact, and the scrolling is
       entirely the visitor's own rather than a transform driven by the page.
       It is also the only mode where tabbing to an off-screen panel is the
       browser's problem rather than this component's. */
  
    /**
     * @returns {void}
     */
    function applyMotionPreference() {
      if (reduced) {
        sleep();
        pinned = false;
        target.classList.remove(PINNED);
        write(0);
        measure();
      } else {
        pinned = true;
        target.classList.add(PINNED);
        invalidate();
      }
    }
  
    cleanups.push(() => {
      target.classList.remove(PINNED);
      track.style.removeProperty('--vc-hh-x');
    });
  
    cleanups.push(
      onReducedMotionChange((next) => {
        reduced = next;
        applyMotionPreference();
      })
    );
  
    applyMotionPreference();
  
    return {
      destroy() {
        while (cleanups.length > 0) cleanups.pop()?.();
      }
    };
  }

  /* ── exports ─────────────────────────────────────────────────────────── */
  var VC = (window.VC = window.VC || {});
  VC.createHorizontalHandoff = createHorizontalHandoff;

  /* Every export in the graph, for checklist assertions from the demo page. */
  VC.__all = {
    el: el,
    svgEl: svgEl,
    on: on,
    prefersReducedMotion: prefersReducedMotion,
    onReducedMotionChange: onReducedMotionChange,
    onFrame: onFrame,
    frameSubscriberCount: frameSubscriberCount,
    createHorizontalHandoff: createHorizontalHandoff
  };
})();

horizontal-handoff.css

2.5KB · 74 lines

Raw
View source
/* GENERATED — run pnpm build:components. Source: packages/registry/horizontal-handoff/horizontal-handoff.css */

/**
 * Horizontal Handoff
 *
 * Reads tokens, never defines them. No global rules — every selector is scoped
 * under .vc-horizontal-handoff. Nothing here paints: no background, no border,
 * no radius. The panels are the buyer's, and this only decides where they sit.
 *
 * The unpinned rules below are the real fallback, not a degraded one. Without
 * the --pinned class this is an ordinary horizontal scroll container: every
 * panel reachable, the layout intact, and the scrolling the visitor's own.
 * That is the reduced-motion mode and the nothing-to-travel mode both.
 */

.vc-horizontal-handoff__viewport {
  overflow-x: auto;
  overflow-y: hidden;
  /* Momentum on iOS, and no vertical rubber-banding fighting the page. */
  overscroll-behavior-x: contain;
}

/**
 * max-content, so the track is as wide as the panels put together rather than
 * as wide as its parent. Without it a flex row inside a clipped box would
 * squeeze every panel down to fit, and there would be nothing to travel.
 */
.vc-horizontal-handoff__track {
  display: flex;
  width: max-content;
  gap: var(--vc-hh-gap, 0);
}

/**
 * Pinned. The viewport sticks to the top of the scrollport for exactly as long
 * as the section is passing through it, and the track moves sideways by the
 * amount of that passage the visitor has covered.
 *
 * The height is a custom property so a buyer can hand it 100svh — which does
 * not resize when mobile browser chrome slides away, and so does not jump
 * mid-handoff — or a fixed height, without touching this file.
 */
.vc-horizontal-handoff--pinned .vc-horizontal-handoff__viewport {
  position: sticky;
  top: 0;
  height: var(--vc-hh-height, 100vh);
  overflow: hidden;
}

.vc-horizontal-handoff--pinned .vc-horizontal-handoff__track {
  height: 100%;
  transform: translate3d(var(--vc-hh-x, 0px), 0, 0);
}

/**
 * Belt and braces. The factory already declines to pin anything under reduced
 * motion; this covers the frame between a preference changing and the factory
 * hearing about it. Returning to `static` is safe here because the viewport is
 * an element the component created and nothing else styles.
 */
@media (prefers-reduced-motion: reduce) {
  .vc-horizontal-handoff--pinned .vc-horizontal-handoff__viewport {
    position: static;
    height: auto;
    overflow-x: auto;
    overflow-y: hidden;
  }

  .vc-horizontal-handoff--pinned .vc-horizontal-handoff__track {
    height: auto;
    transform: none;
  }
}