Exclusivobeta

Cursor Snap <mv-cursor-snap>

Efecto de puntero premium que conserva el cursor del sistema: una placa suave se adapta al enlace o botón bajo el puntero (su caja y su radio de esquina), entra con suavidad, se inclina un poco hacia el puntero y se desliza entre objetivos. A diferencia de los cursores personalizados, no oculta nada y hace visible el área de clic, y el foco del teclado recibe la misma placa.

CategoríaCursores
TipoWeb Component (<mv-cursor-snap>)
Estadobeta
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

Instalación

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

Agente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["cursor-snap"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Archivos copiados (dependencias incluidas): 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.

Uso

Inicio rápido, el marcado mínimo que funciona:

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

Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:

<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

NameTipoDefaultDescription
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

NameTipoDescription
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.

Esta página se tradujo con IA. Informar de un problema de traducción