익스클루시브
Cursor Snap <mv-cursor-snap>
시스템 커서를 유지하는 프리미엄 포인터 효과: 부드러운 플레이트가 호버한 링크나 버튼(박스와 모서리 반경)에 맞게 변형되고, 부드럽게 나타나며, 포인터 쪽으로 살짝 기울고, 대상 사이를 미끄러지듯 이동합니다.
작동 방식
시스템 커서를 유지하는 프리미엄 포인터 효과: 부드러운 플레이트가 호버한 링크나 버튼(박스와 모서리 반경)에 맞게 변형되고, 부드럽게 나타나며, 포인터 쪽으로 살짝 기울고, 대상 사이를 미끄러지듯 이동합니다. 커스텀 커서와 달리 아무것도 가리지 않고 클릭 영역을 보이게 하며, 키보드 포커스에도 같은 플레이트가 적용됩니다.
| 카테고리 | 커서 |
|---|---|
| 유형 | Web Component (<mv-cursor-snap>) |
| 상태 | 안정 |
| 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
설치
node scripts/add.mjs cursor-snap --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["cursor-snap"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): 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.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-cursor-snap>
<nav><a href="/pricing">Pricing</a> <button>Sign in</button></nav>
</mv-cursor-snap>기본 마크업입니다. 여기서 시작해 속성, data-*, 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">Saltmarsh 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 | 유형 | 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 | 유형 | 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. Known limits: the plate is one translucent layer above the content (z-index --mv-z-cursor), so it lightly tints the target text and can sit under a higher layer such as a modal or a sticky header; in global mode the plate is fixed to the viewport and follows scroll on the next frame; targets are matched by selector (targets), so custom controls need a role, data-snap or a custom selector to be picked up.