Blend Cursor

A disc that replaces the pointer and inverts whatever it passes over.

free vanilla 14.7KB cursorpointerblendoverlay
Move the pointer into the frame, then over a button.

Options

Option Type Default Notes
size number 24 Disc diameter in px.
hoverSize number 64 Diameter over something clickable, in px.
ease number 0.18 Fraction of the remaining distance closed each 60fps frame. 1 follows exactly, with no lag.
mode string "invert" 'invert' inverts the backdrop and needs no colour. 'blend' paints color through blendMode.
color string "var(--vc-accent)" Fill for blend mode. Must stay distinguishable from the page in every theme.
blendMode string "difference" Any CSS mix-blend-mode, used by blend mode.
hoverSelector string "a, button, [role=\"button\"], [data-cursor=\"hover\"]" What counts as clickable, and so makes the disc swell.
nativeSelector string "input, textarea, select, [contenteditable=\"true\"]" Where the real cursor is handed back, because a disc over a text field hides the caret.

Install

  1. Copy blend-cursor.js and blend-cursor.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 createBlendCursor on a <div>.
<link rel="stylesheet" href="tokens.css" />
<link rel="stylesheet" href="blend-cursor.css" />

<script defer src="blend-cursor.js"></script>
const instance = VC.createBlendCursor(
  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 createBlendCursor on window.VC. If you would rather have the ES module, it is packages/registry/blend-cursor/ in the repo — same source, four files once _utils/ comes with it.

blend-cursor.js

21.9KB · 702 lines

Raw
View source
/**
 * GENERATED — run pnpm build:components — do not edit.
 *
 * Source of truth: packages/registry/blend-cursor/blend-cursor.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.createBlendCursor
 */
(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/blend-cursor/blend-cursor.js ────────────────── */
  /**
   * Blend Cursor
   *
   * Replaces the pointer with a disc that inverts whatever it passes over, and
   * swells when there is something to click. The native cursor is hidden only
   * while this one is actually running, so nothing can leave a page with no
   * cursor at all.
   *
   * @see NOTES.md
   */
  
  
  
  
  
  const BASE = 'vc-blend-cursor';
  
  /** Applied to the target while the disc is live. Hides the native cursor. */
  const HOST = `${BASE}-host`;
  
  /** Below this the disc is treated as having arrived, in px. */
  const REST = 0.05;
  
  /** One frame at 60fps, ms. The reference step the easing is expressed in. */
  const STEP = 1000 / 60;
  
  /** Longest frame the easing will accept, ms. Beyond this it is a tab-out. */
  const MAX_STEP = 64;
  
  /**
   * Targets with a live instance. Two discs over one element would fight over
   * the native cursor, so a second mount is an error.
   *
   * @type {WeakSet<HTMLElement>}
   */
  const mounted = new WeakSet();
  
  /**
   * @typedef {object} BlendCursorOptions
   * @property {number} [size] Disc diameter, px.
   * @property {number} [hoverSize] Diameter over something clickable, px.
   * @property {number} [ease] How much of the remaining distance is closed each
   *   60fps frame, 0–1. 1 follows the pointer exactly, with no lag.
   * @property {'invert' | 'blend'} [mode] `invert` inverts the backdrop and needs
   *   no colour. `blend` paints `color` through `blendMode`.
   * @property {string} [color] Fill for `blend` mode. Must stay distinguishable
   *   from the page in every theme — see NOTES.md.
   * @property {string} [blendMode] Any CSS mix-blend-mode, for `blend` mode.
   * @property {string} [hoverSelector] What counts as clickable.
   * @property {string} [nativeSelector] Where the real cursor is handed back,
   *   because a disc over a text field hides the caret.
   */
  
  /**
   * @typedef {object} BlendCursorInstance
   * @property {() => void} destroy
   */
  
  /**
   * Whether this machine has a pointer that can hover — a mouse or a trackpad,
   * not a finger. Read live, because a tablet gains one the moment a mouse is
   * paired and loses it again when that mouse goes away.
   *
   * @returns {MediaQueryList | null}
   */
  function finePointerQuery() {
    if (typeof matchMedia !== 'function') return null;
    return matchMedia('(pointer: fine)');
  }
  
  /**
   * @param {HTMLElement} target
   * @param {BlendCursorOptions} [options]
   * @returns {BlendCursorInstance}
   */
  function createBlendCursor(target, options = {}) {
    if (mounted.has(target)) {
      throw new Error(
        'createBlendCursor: this element already has an instance — destroy it first'
      );
    }
  
    const {
      size = 24,
      hoverSize = 64,
      ease = 0.18,
      mode = 'invert',
      color = 'var(--vc-accent)',
      blendMode = 'difference',
      hoverSelector = 'a, button, [role="button"], [data-cursor="hover"]',
      nativeSelector = 'input, textarea, select, [contenteditable="true"]'
    } = options;
  
    /** @type {Array<() => void>} */
    const cleanups = [];
  
    mounted.add(target);
    cleanups.push(() => mounted.delete(target));
  
    /* ── DOM ───────────────────────────────────────────────────────────────
       Two elements, because they animate on different clocks. The outer one is
       moved every frame by JavaScript and must never carry a transition. The
       inner one swells on hover through a CSS transition, and must never be
       written to per frame. Putting both transforms on one element would mean a
       transition fighting a sixty-times-a-second rewrite.
  
       aria-hidden, and pointer-events: none. It is decoration that must never
       intercept the click it is pointing at. */
  
    const root = el('div', { class: BASE, 'aria-hidden': 'true' });
    const disc = el('div', { class: `${BASE}__disc ${BASE}__disc--${mode}` });
    root.append(disc);
  
    root.style.setProperty('--vc-bc-size', `${size}px`);
    root.style.setProperty('--vc-bc-swell', String(size > 0 ? hoverSize / size : 1));
  
    if (mode === 'blend') {
      disc.style.setProperty('--vc-bc-color', color);
      disc.style.setProperty('--vc-bc-blend', blendMode);
    }
  
    /* ── State ─────────────────────────────────────────────────────────────── */
  
    let pointerX = 0;
    let pointerY = 0;
  
    let x = 0;
    let y = 0;
  
    /** Written to the DOM last, so a frame that changes nothing writes nothing. */
    let wroteX = Number.NaN;
    let wroteY = Number.NaN;
  
    /** False until the pointer has been seen once, so the disc never flies in. */
    let placed = false;
  
    let reduced = prefersReducedMotion();
    let attached = false;
  
    /** @type {(() => void) | null} Live rAF subscription. */
    let frameHandle = null;
  
    /** @type {Array<() => void>} Bound only while the disc is attached. */
    let bindings = [];
  
    /**
     * @returns {void}
     */
    function write() {
      const nextX = Math.round(x * 100) / 100;
      const nextY = Math.round(y * 100) / 100;
      if (nextX === wroteX && nextY === wroteY) return;
  
      wroteX = nextX;
      wroteY = nextY;
      root.style.transform = `translate3d(${nextX}px, ${nextY}px, 0)`;
    }
  
    /* ── Frames ────────────────────────────────────────────────────────────── */
  
    /**
     * Ease toward the pointer, then let go of the frame.
     *
     * The easing is expressed per 60fps frame and then corrected for the frame
     * actually delivered, so the disc trails by the same distance on a 120Hz
     * display as on a 60Hz one. A raw `x += (target - x) * ease` would close the
     * gap twice as fast on the faster screen and feel like a different component.
     *
     * @param {number} _time Unused: the shared loop passes it first.
     * @param {number} delta
     * @returns {void}
     */
    function frame(_time, delta) {
      const steps = Math.min(delta, MAX_STEP) / STEP;
      const k = steps <= 0 ? ease : 1 - (1 - ease) ** steps;
  
      x += (pointerX - x) * k;
      y += (pointerY - y) * k;
  
      if (Math.abs(pointerX - x) < REST && Math.abs(pointerY - y) < REST) {
        /* Land exactly, so a stray hundredth of a pixel cannot hold the loop
           open forever. */
        x = pointerX;
        y = pointerY;
        write();
        sleep();
        return;
      }
  
      write();
    }
  
    /**
     * @returns {void}
     */
    function wake() {
      if (frameHandle || reduced || !attached) return;
      frameHandle = onFrame(frame);
    }
  
    /**
     * @returns {void}
     */
    function sleep() {
      if (!frameHandle) return;
      frameHandle();
      frameHandle = null;
    }
  
    cleanups.push(sleep);
  
    /* ── Pointer ───────────────────────────────────────────────────────────── */
  
    /**
     * @param {PointerEvent} event
     * @returns {void}
     */
    function onMove(event) {
      pointerX = event.clientX;
      pointerY = event.clientY;
  
      /* First sighting, or no easing wanted: go straight there. Easing in from
         the top-left corner on the first move is the classic tell that a custom
         cursor was bolted on. */
      if (!placed || reduced || ease >= 1) {
        placed = true;
        x = pointerX;
        y = pointerY;
        write();
        root.classList.add(`${BASE}--visible`);
        return;
      }
  
      root.classList.add(`${BASE}--visible`);
      wake();
    }
  
    /**
     * The disc follows the pointer, so when the pointer is gone the disc has
     * nothing to say. Hidden rather than removed, so coming back is instant.
     *
     * @returns {void}
     */
    function onLeave() {
      root.classList.remove(`${BASE}--visible`);
      sleep();
    }
  
    /**
     * Hover and hand-back, from one delegated listener rather than a hit test
     * per frame. `pointerover` fires on every boundary crossing inside the
     * target, which is exactly when either answer can change.
     *
     * @param {PointerEvent} event
     * @returns {void}
     */
    function onOver(event) {
      const node = event.target;
      if (!(node instanceof Element)) return;
  
      /* A disc sitting over a text field hides the caret and the I-beam that
         says the field is editable. Hand the real cursor back and get out of
         the way — the page is more important than the effect. */
      const native = Boolean(nativeSelector) && node.closest(nativeSelector) !== null;
      target.classList.toggle(HOST, !native);
      root.classList.toggle(`${BASE}--yielded`, native);
  
      const hovering = Boolean(hoverSelector) && node.closest(hoverSelector) !== null;
      root.classList.toggle(`${BASE}--hover`, hovering);
    }
  
    /* ── Attach ────────────────────────────────────────────────────────────
       Everything is put up and taken down together, because every reason to
       stop — no fine pointer, destroyed — is a reason to stop all of it. The
       native cursor is hidden here and nowhere else, so there is no path where
       the page ends up with neither cursor. */
  
    /**
     * @returns {void}
     */
    function attach() {
      if (attached) return;
      attached = true;
  
      placed = false;
      target.append(root);
      target.classList.add(HOST);
  
      bindings = [
        on(target, 'pointermove', /** @type {(e: Event) => void} */ (onMove), {
          passive: true
        }),
        on(target, 'pointerover', /** @type {(e: Event) => void} */ (onOver), {
          passive: true
        }),
        on(target, 'pointerleave', onLeave),
        on(window, 'blur', onLeave)
      ];
    }
  
    /**
     * @returns {void}
     */
    function detach() {
      if (!attached) return;
      attached = false;
  
      sleep();
  
      for (const off of bindings) off();
      bindings = [];
  
      root.classList.remove(`${BASE}--visible`, `${BASE}--hover`, `${BASE}--yielded`);
      root.remove();
      target.classList.remove(HOST);
  
      wroteX = Number.NaN;
      wroteY = Number.NaN;
    }
  
    cleanups.push(detach);
  
    /* ── Fine pointer ─────────────────────────────────────────────────────
       A finger has no hover state and no position between taps, so on a touch
       screen this component has nothing to draw and no native cursor worth
       hiding. It puts nothing in the DOM at all until there is a pointer that
       can hover, and takes it all down again if that pointer goes away. */
  
    const fine = finePointerQuery();
  
    /**
     * @returns {void}
     */
    function applyPointerType() {
      if (fine === null || fine.matches) attach();
      else detach();
    }
  
    if (fine) {
      const listener = () => applyPointerType();
      fine.addEventListener('change', listener);
      cleanups.push(() => fine.removeEventListener('change', listener));
    }
  
    /* ── Reduced motion ───────────────────────────────────────────────────
       The lag is the motion, not the disc. So the disc stays — it is a cursor,
       and taking it away would leave the page without the one thing the pointer
       needs — and it simply stops trailing: it is written straight to the
       pointer on every move, and no frame is ever taken. */
  
    cleanups.push(
      onReducedMotionChange((next) => {
        reduced = next;
        if (!reduced) return;
        sleep();
        x = pointerX;
        y = pointerY;
        write();
      })
    );
  
    applyPointerType();
  
    return {
      destroy() {
        while (cleanups.length > 0) cleanups.pop()?.();
      }
    };
  }

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

  /* 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,
    createBlendCursor: createBlendCursor
  };
})();

blend-cursor.css

3.1KB · 100 lines

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

/**
 * Blend Cursor
 *
 * Reads tokens, never defines them. No global rules — every selector is scoped
 * under .vc-blend-cursor, and the one rule that reaches outside it is scoped to
 * .vc-blend-cursor-host, a class the factory adds and removes.
 *
 * Nothing here hides the native cursor except that host class, and the factory
 * only ever adds it while the disc is actually on screen. A stylesheet that
 * hid the cursor on its own would leave a page whose script failed to load
 * with no cursor at all.
 */

.vc-blend-cursor-host {
  cursor: none;
}

/**
 * The outer element carries position only, rewritten every frame from
 * JavaScript, and must never have a transition on transform — a transition
 * here would be a second animation fighting the easing.
 *
 * Centred by margin rather than by a translate(-50%, -50%) baked into the
 * transform, so the per-frame write stays a plain translate3d.
 */
.vc-blend-cursor {
  position: fixed;
  top: 0;
  left: 0;
  z-index: var(--vc-z-tooltip);
  width: var(--vc-bc-size, 24px);
  height: var(--vc-bc-size, 24px);
  margin: calc(var(--vc-bc-size, 24px) / -2);
  pointer-events: none;
  opacity: 0;
  transition: opacity var(--vc-dur-fast) var(--vc-ease-out);
}

.vc-blend-cursor--visible {
  opacity: 1;
}

/* Over a text field the real cursor is back, so this one gets out of the way. */
.vc-blend-cursor--yielded {
  opacity: 0;
}

/**
 * The inner element carries size and appearance, and swells on hover through a
 * transition. Separating the two transforms is what lets a CSS transition and
 * a per-frame write coexist on the same cursor.
 */
.vc-blend-cursor__disc {
  width: 100%;
  height: 100%;
  border-radius: var(--vc-radius-full);
  transform: scale(1);
  transition: transform var(--vc-dur-base) var(--vc-ease-spring);
}

.vc-blend-cursor--hover .vc-blend-cursor__disc {
  transform: scale(var(--vc-bc-swell, 2.5));
}

/**
 * Inverting the backdrop needs no colour at all, which is the whole reason it
 * is the default. Every fill that could be named instead collapses in one
 * theme or the other: measured against the token set, --vc-bg is invisible in
 * dark and --vc-ink is invisible in light, because `difference` against a
 * backdrop equal to the fill is black.
 */
.vc-blend-cursor__disc--invert {
  backdrop-filter: invert(1);
  -webkit-backdrop-filter: invert(1);
}

/**
 * The named-colour alternative. --vc-accent is the default because it is the
 * one colour token that is mid-tone in both themes by construction, so the
 * difference against the page is large either way.
 */
.vc-blend-cursor__disc--blend {
  background: var(--vc-bc-color, var(--vc-accent));
  mix-blend-mode: var(--vc-bc-blend, difference);
}

/**
 * Belt and braces. The factory already stops easing and writes the disc
 * straight to the pointer under reduced motion; this covers the swell, which
 * is a CSS transition the factory has no hand in.
 */
@media (prefers-reduced-motion: reduce) {
  .vc-blend-cursor,
  .vc-blend-cursor__disc {
    transition: none;
  }
}