ExklusivBeta
Cursor Snap <mv-cursor-snap>
Premium-Zeigereffekt, der den System-Cursor behält: Eine weiche Platte formt sich zum überfahrenen Link oder Button (seine Box und sein Eckenradius), gleitet sanft herein, neigt sich leicht zum Zeiger und gleitet zwischen den Zielen. Anders als eigene Cursor verdeckt er nichts und macht die Trefferfläche sichtbar, und der Tastaturfokus bekommt dieselbe Platte.
| Kategorie | Cursor |
|---|---|
| Typ | Web Component (<mv-cursor-snap>) |
| Status | Beta |
| 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/marvelousKI-Agent mit dem MCP-Server von Marvelous UI: install_components({ slugs: ["cursor-snap"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Kopierte Dateien (inklusive Abhängigkeiten): 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.
Verwendung
Schnellstart mit dem kleinsten funktionierenden Markup:
<mv-cursor-snap>
<nav><a href="/pricing">Pricing</a> <button>Sign in</button></nav>
</mv-cursor-snap>Referenz-Markup zum Starten und Anpassen mit Attributen, data-* und CSS-Variablen:
<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 | Typ | 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 | Typ | 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.