Exclusivebeta

Cursor Snap <mv-cursor-snap>

Premium pointer effect that keeps the system cursor: a soft plate morphs to the hovered link or button (its box and corner radius), eases in, leans slightly toward the pointer and glides between targets. Unlike custom cursors it hides nothing and makes the hit area visible, and keyboard focus gets the same plate.

CategoryCursors
TypeWeb Component (<mv-cursor-snap>)
Statusbeta
Keywordsexclusive, light, cursor, hover, highlight, focus-visible, pointer, snap, hit-area, morph

When to use

  • A marketing site or portfolio wants a polished hover effect on its nav and buttons without replacing the cursor
  • A toolbar or icon row should show a highlight that glides from one button to the next
  • Hover and keyboard focus should share one clear affordance on links, buttons and tabs
  • Small or tightly packed targets should reveal their clickable area as the pointer approaches

Avoid when

  • The design calls for a custom dot-and-ring cursor with labels such as 'View' → use Cursor Follower instead
  • A single hero button should be pulled toward the pointer → use Magnetic instead
  • A tooltip, not a highlight, should travel between toolbar buttons → use Tooltip Group instead

Install

node scripts/add.mjs cursor-snap --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["cursor-snap"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/cursor-snap/cursor-snap.js, components/cursor-snap/cursor-snap.css.

Usage

Quick start, the smallest working markup:

<mv-cursor-snap>
  <nav><a href="/pricing">Pricing</a> <button>Sign in</button></nav>
</mv-cursor-snap>

Canonical markup, to start from and customize with attributes, data-* and CSS variables:

<div id="mv-cs-demo">
  <style>
    #mv-cs-demo { display: grid; gap: 1rem; width: min(100%, 52rem); margin-inline: auto; }
    #mv-cs-demo .site { border: 1px solid var(--mv-border); border-radius: var(--mv-radius-xl); background: var(--mv-surface); overflow: hidden; }
    #mv-cs-demo .bar { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem 1.25rem; padding: .75rem 1rem .75rem 1.25rem; border-bottom: 1px solid var(--mv-border); }
    #mv-cs-demo .logo { font-weight: var(--mv-weight-semibold); letter-spacing: -.01em; margin-right: auto; white-space: nowrap; }
    @media (max-width: 36rem) { #mv-cs-demo .bar nav { order: 1; width: 100%; margin-left: -.6rem; } }
    #mv-cs-demo nav { display: flex; gap: .25rem; }
    #mv-cs-demo nav a { padding: .35rem .6rem; border-radius: var(--mv-radius-sm); color: var(--mv-fg-muted); text-decoration: none; font-size: .88rem; }
    #mv-cs-demo nav a[aria-current] { color: var(--mv-fg); }
    #mv-cs-demo .hero { display: grid; gap: .9rem; justify-items: start; padding: 1.75rem 1.5rem 1.6rem; }
    #mv-cs-demo h3 { margin: 0; font-size: clamp(1.3rem, 2.4vw, 1.7rem); letter-spacing: -.02em; line-height: 1.15; }
    #mv-cs-demo p { margin: 0; color: var(--mv-fg-muted); font-size: .92rem; max-width: 34rem; }
    #mv-cs-demo .row { display: flex; flex-wrap: wrap; gap: .6rem; align-items: center; }
    #mv-cs-demo .row small { color: var(--mv-fg-subtle); font-size: .78rem; }
    #mv-cs-demo .row small a { color: inherit; }
    #mv-cs-demo .tools { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; justify-content: space-between; padding: .75rem 1rem; border: 1px solid var(--mv-border); border-radius: var(--mv-radius-xl); background: var(--mv-surface); }
    #mv-cs-demo .toolbar { display: flex; gap: .15rem; padding: .25rem; border-radius: var(--mv-radius-lg); background: var(--mv-bg-muted); }
    #mv-cs-demo .toolbar button { display: grid; place-items: center; width: 2rem; height: 2rem; border: 0; border-radius: var(--mv-radius-md); background: none; color: var(--mv-fg); cursor: pointer; }
    #mv-cs-demo .toolbar button[aria-pressed="true"] { background: var(--mv-bg); box-shadow: var(--mv-shadow-xs); }
    #mv-cs-demo .toolbar svg { width: 1rem; height: 1rem; }
    #mv-cs-demo .toolbar hr { width: 1px; margin: .35rem .2rem; border: 0; background: var(--mv-border); }
    #mv-cs-demo .note { color: var(--mv-fg-muted); font-size: .8rem; }
    #mv-cs-demo label.opt { display: inline-flex; gap: .5rem; align-items: center; font-size: .85rem; }
  </style>

  <mv-cursor-snap class="site" id="mv-cs-site">
    <div class="bar">
      <span class="logo">Lumen Travel</span>
      <nav aria-label="Main">
        <a href="#" aria-current="page">Stays</a>
        <a href="#">Flights</a>
        <a href="#">Deals</a>
        <a href="#">Help</a>
      </nav>
      <button class="mv-button" data-variant="ghost" data-size="icon" data-shape="round" aria-label="Account">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>
      </button>
    </div>
    <div class="hero">
      <h3>Lisbon to Kyoto, from $612 round trip</h3>
      <p>Fares updated Sep 24, 2026. Move the pointer over the links and buttons, or press Tab: the same plate marks what you can click, and your own cursor stays exactly as it is.</p>
      <div class="row">
        <button class="mv-button">Search flights</button>
        <button class="mv-button" data-variant="outline">Set a price alert</button>
        <small>Prices include taxes. <a href="#">Fare rules</a></small>
      </div>
    </div>
  </mv-cursor-snap>

  <div class="tools">
    <mv-cursor-snap id="mv-cs-tools" style="--mv-cursor-snap-pad:0px;--mv-cursor-snap-color:color-mix(in oklch, var(--mv-fg) 8%, transparent);--mv-cursor-snap-ring:transparent">
      <div class="toolbar" role="toolbar" aria-label="Formatting">
        <button aria-label="Bold" aria-pressed="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z"/></svg></button>
        <button aria-label="Italic" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M14 5h5M5 19h5M15 5 9 19"/></svg></button>
        <button aria-label="Underline" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M7 4v7a5 5 0 0 0 10 0V4M5 20h14"/></svg></button>
        <hr>
        <button aria-label="Insert link"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg></button>
        <button aria-label="Bulleted list"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01"/></svg></button>
      </div>
    </mv-cursor-snap>
    <span class="note">Neutral tint, flush with the buttons</span>
    <label class="opt"><input type="checkbox" role="switch" class="mv-switch" id="mv-cs-toggle" checked> Snap highlight</label>
  </div>

  <script type="module">
    const toggle = document.getElementById("mv-cs-toggle");
    toggle?.addEventListener("change", () => {
      for (const id of ["mv-cs-site", "mv-cs-tools"]) document.getElementById(id).disabled = !toggle.checked;
    });
    document.querySelectorAll("#mv-cs-tools [aria-pressed]").forEach((b) => b.addEventListener("click", () => {
      b.setAttribute("aria-pressed", String(b.getAttribute("aria-pressed") !== "true"));
    }));
  </script>
</div>

API

Attributes

NameTypeDefaultDescription
globalbooleanWhole page instead of the element's own content: place one empty <mv-cursor-snap global> anywhere (the plate is attached to <body>, fixed to the viewport).
targetsCSS selectora[href], button:not(:disabled), [role='button']:not([aria-disabled='true']), [role='tab'], summary, [data-snap]Elements that receive the plate.
disabledbooleanTurns the effect off and removes the plate.
data-snap (on your elements)(empty) | offEmpty: opt any element in (a card, a custom control). off: never highlight this element.

Properties

NameTypeDescription
currentElement | nullElement the plate is on right now (read only).

Content structure

NameDescription
(content)The area where the effect applies (unless global).

CSS classes

NameDescription
mv-cursor-snap-plateThe single highlight element, created by the component (aria-hidden, pointer-events: none).

CSS variables

NameDefaultDescription
--mv-cursor-snap-coloraccent at 11%Plate fill.
--mv-cursor-snap-ringaccent at 26%Hairline inside the plate edge (transparent to remove).
--mv-cursor-snap-pad0.25remHow far the plate extends beyond the target's box (0 = flush).
--mv-cursor-snap-radiusvar(--mv-radius-md)Corner radius used when the target itself has square corners (otherwise its own radius plus the pad).
--mv-cursor-snap-zvar(--mv-z-cursor)Stacking order of the plate.

Accessibility

The system cursor is never hidden, replaced or resized, so OS cursor size and contrast settings keep working; the plate only makes the target's real hit area visible (helps motor accuracy). The plate is decorative (aria-hidden, pointer-events: none) and adds nothing to the accessibility tree. Keyboard: a target reached with :focus-visible gets the same plate, so keyboard and pointer users share one affordance (it adds to, never replaces, the element's own focus ring). The pointer part runs only under (hover: hover) and (pointer: fine) and ignores touch; keyboard focus works everywhere. Typing hides the hover plate until the pointer moves again. Reduced motion (OS or data-motion="reduce"): no easing, parallax or glide, the plate simply appears on the hovered or focused target. Forced colors: a Highlight outline instead of the tinted fill, without motion. Disabled buttons and aria-disabled controls are skipped. Transforms only (the plate is resized once per target, then scaled), one element, a frame loop that stops as soon as the plate rests; everything is removed when the element is removed.