Exclusifbêta
Cursor Snap <mv-cursor-snap>
Effet de pointeur premium qui conserve le curseur système : une plaque douce prend la forme du lien ou du bouton survolé (sa boîte et son rayon d’angle), apparaît en douceur, penche légèrement vers le pointeur et glisse d’une cible à l’autre. Contrairement aux curseurs personnalisés, il ne masque rien et rend la zone cliquable visible, et le focus clavier reçoit la même plaque.
| Catégorie | Curseurs |
|---|---|
| Type | Web Component (<mv-cursor-snap>) |
| Statut | bêta |
| Keywords | exclusive, 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
Installation
node scripts/add.mjs cursor-snap --out ./src/marvelousAgent IA avec le serveur MCP de Marvelous UI : install_components({ slugs: ["cursor-snap"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : 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.
Utilisation
Démarrage rapide, le balisage minimal qui fonctionne :
<mv-cursor-snap>
<nav><a href="/pricing">Pricing</a> <button>Sign in</button></nav>
</mv-cursor-snap>Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les 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
| Name | Type | Default | Description |
|---|---|---|---|
global | boolean | Whole 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). | |
targets | CSS selector | a[href], button:not(:disabled), [role='button']:not([aria-disabled='true']), [role='tab'], summary, [data-snap] | Elements that receive the plate. |
disabled | boolean | Turns the effect off and removes the plate. | |
data-snap (on your elements) | (empty) | off | Empty: opt any element in (a card, a custom control). off: never highlight this element. |
Properties
| Name | Type | Description |
|---|---|---|
current | Element | null | Element the plate is on right now (read only). |
Content structure
| Name | Description |
|---|---|
(content) | The area where the effect applies (unless global). |
CSS classes
| Name | Description |
|---|---|
mv-cursor-snap-plate | The single highlight element, created by the component (aria-hidden, pointer-events: none). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-cursor-snap-color | accent at 11% | Plate fill. |
--mv-cursor-snap-ring | accent at 26% | Hairline inside the plate edge (transparent to remove). |
--mv-cursor-snap-pad | 0.25rem | How far the plate extends beyond the target's box (0 = flush). |
--mv-cursor-snap-radius | var(--mv-radius-md) | Corner radius used when the target itself has square corners (otherwise its own radius plus the pad). |
--mv-cursor-snap-z | var(--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.