익스클루시브
Drag Scroll <mv-drag-scroll>
가로로 넘치는 표, 카드 레일, 타임라인, 보드를 감싸 마우스 사용자도 보고 쓸 수 있게 합니다: 숨겨진 쪽에만 나타나는 가장자리 페이드와 “More” 힌트, 관성이 있는 잡아 끌기, 가로 방향으로 바뀌는 일반 휠, 선택적 페이지 이동 버튼.
작동 방식
가로로 넘치는 표, 카드 레일, 타임라인, 보드를 감싸 마우스 사용자도 보고 쓸 수 있게 합니다: 숨겨진 쪽에만 나타나는 가장자리 페이드와 “More” 힌트, 관성이 있는 잡아 끌기, 가로 방향으로 바뀌는 일반 휠, 선택적 페이지 이동 버튼. 일반적인 드래그 스크롤러와 달리 내부의 클릭, 텍스트 선택(잠시 멈춘 뒤 드래그), 네이티브 링크 및 이미지 드래그가 절대 깨지지 않고, 트랙패드와 터치는 네이티브 동작 그대로이며, 페이지의 휠을 빼앗지 않습니다.
| 카테고리 | 스크롤 |
|---|---|
| 유형 | Web Component (<mv-drag-scroll>) |
| 상태 | 안정 |
| Keywords | exclusive, light, horizontal-scroll, drag, grab, overflow, table, rail, kanban, edge-fade, momentum |
When to use
- A wide data table overflows on laptops and desktop mouse users must find and reach the hidden columns
- A row of product, trip or article cards scrolls sideways and should be draggable with optional arrow buttons
- A kanban board or horizontal timeline is wider than the screen and should pan by dragging its background
Avoid when
- Items should be browsed one or a few at a time with dots, looping or autoplay → use Carousel instead
- The region scrolls vertically and mainly needs a thin styled scrollbar → use Scroll Area instead
- Logos or quotes should move on their own in an endless loop → use Marquee instead
설치
node scripts/add.mjs drag-scroll --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["drag-scroll"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/drag-scroll/drag-scroll.js, components/drag-scroll/drag-scroll.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-drag-scroll>
<table>…wide table…</table>
</mv-drag-scroll>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="mv-drag-scroll-demo" style="display:grid;gap:1.5rem;width:100%;max-width:880px;margin-inline:auto">
<section style="border:1px solid var(--mv-border);border-radius:var(--mv-radius-xl);background:var(--mv-surface);overflow:hidden">
<h3 style="margin:0;padding:.85rem 1.1rem .6rem;font-size:.9rem">Revenue by region, fiscal 2026</h3>
<mv-drag-scroll label="Revenue by region, fiscal 2026" style="border-radius:0">
<table style="width:max-content;min-width:100%;border-collapse:collapse;font-size:.85rem;font-variant-numeric:tabular-nums;white-space:nowrap">
<thead>
<tr style="color:var(--mv-fg-muted);text-align:end;border-block:1px solid var(--mv-border);background:var(--mv-bg-subtle)">
<th scope="col" style="text-align:start;padding:.55rem 1.1rem;font-weight:500">Region</th>
<th scope="col" style="padding:.55rem 1.1rem;font-weight:500">Jan</th><th scope="col" style="padding:.55rem 1.1rem;font-weight:500">Feb</th><th scope="col" style="padding:.55rem 1.1rem;font-weight:500">Mar</th><th scope="col" style="padding:.55rem 1.1rem;font-weight:500">Apr</th><th scope="col" style="padding:.55rem 1.1rem;font-weight:500">May</th><th scope="col" style="padding:.55rem 1.1rem;font-weight:500">Jun</th><th scope="col" style="padding:.55rem 1.1rem;font-weight:500">Jul</th><th scope="col" style="padding:.55rem 1.1rem;font-weight:500">Aug</th><th scope="col" style="padding:.55rem 1.1rem;font-weight:500">Sep</th><th scope="col" style="padding:.55rem 1.1rem;font-weight:500">Total</th>
</tr>
</thead>
<tbody style="text-align:end">
<tr style="border-bottom:1px solid var(--mv-border)"><th scope="row" style="text-align:start;padding:.55rem 1.1rem;font-weight:500"><a href="#na" style="color:var(--mv-accent)">North America</a></th><td style="padding:.55rem 1.1rem">$412,300</td><td style="padding:.55rem 1.1rem">$398,750</td><td style="padding:.55rem 1.1rem">$451,020</td><td style="padding:.55rem 1.1rem">$467,480</td><td style="padding:.55rem 1.1rem">$489,110</td><td style="padding:.55rem 1.1rem">$502,640</td><td style="padding:.55rem 1.1rem">$478,900</td><td style="padding:.55rem 1.1rem">$495,315</td><td style="padding:.55rem 1.1rem">$521,860</td><td style="padding:.55rem 1.1rem;font-weight:600">$4,217,375</td></tr>
<tr style="border-bottom:1px solid var(--mv-border)"><th scope="row" style="text-align:start;padding:.55rem 1.1rem;font-weight:500"><a href="#eu" style="color:var(--mv-accent)">Europe</a></th><td style="padding:.55rem 1.1rem">$286,940</td><td style="padding:.55rem 1.1rem">$274,300</td><td style="padding:.55rem 1.1rem">$301,780</td><td style="padding:.55rem 1.1rem">$318,450</td><td style="padding:.55rem 1.1rem">$322,090</td><td style="padding:.55rem 1.1rem">$309,660</td><td style="padding:.55rem 1.1rem">$265,210</td><td style="padding:.55rem 1.1rem">$251,480</td><td style="padding:.55rem 1.1rem">$334,920</td><td style="padding:.55rem 1.1rem;font-weight:600">$2,664,830</td></tr>
<tr style="border-bottom:1px solid var(--mv-border)"><th scope="row" style="text-align:start;padding:.55rem 1.1rem;font-weight:500"><a href="#apac" style="color:var(--mv-accent)">Asia Pacific</a></th><td style="padding:.55rem 1.1rem">$198,420</td><td style="padding:.55rem 1.1rem">$231,060</td><td style="padding:.55rem 1.1rem">$244,890</td><td style="padding:.55rem 1.1rem">$239,170</td><td style="padding:.55rem 1.1rem">$257,330</td><td style="padding:.55rem 1.1rem">$268,540</td><td style="padding:.55rem 1.1rem">$281,700</td><td style="padding:.55rem 1.1rem">$290,215</td><td style="padding:.55rem 1.1rem">$302,480</td><td style="padding:.55rem 1.1rem;font-weight:600">$2,313,805</td></tr>
<tr><th scope="row" style="text-align:start;padding:.55rem 1.1rem;font-weight:500"><a href="#latam" style="color:var(--mv-accent)">Latin America</a></th><td style="padding:.55rem 1.1rem">$74,810</td><td style="padding:.55rem 1.1rem">$79,230</td><td style="padding:.55rem 1.1rem">$81,650</td><td style="padding:.55rem 1.1rem">$88,940</td><td style="padding:.55rem 1.1rem">$92,370</td><td style="padding:.55rem 1.1rem">$95,020</td><td style="padding:.55rem 1.1rem">$97,860</td><td style="padding:.55rem 1.1rem">$101,445</td><td style="padding:.55rem 1.1rem">$106,190</td><td style="padding:.55rem 1.1rem;font-weight:600">$817,515</td></tr>
</tbody>
</table>
</mv-drag-scroll>
</section>
<section>
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:1rem;margin-bottom:.6rem">
<h3 style="margin:0;font-size:.95rem">Trips departing in October</h3>
<output data-demo-status style="font-size:.8rem;color:var(--mv-fg-muted)">Drag the row, or use the arrows</output>
</div>
<mv-drag-scroll buttons label="Trips departing in October">
<ul style="display:flex;gap:.85rem;margin:0;padding:.15rem .15rem .6rem;list-style:none">
<li><a href="#lisbon" data-trip="Lisbon" style="display:grid;gap:.45rem;width:13rem;color:inherit;text-decoration:none"><span style="display:block;aspect-ratio:4/3;border-radius:var(--mv-radius-lg);background:linear-gradient(135deg,oklch(0.78 0.12 60),oklch(0.62 0.15 25))"></span><span style="font-weight:600;font-size:.9rem">Lisbon, Portugal</span><span style="font-size:.8rem;color:var(--mv-fg-muted)">Oct 4 to Oct 9 · from $1,240</span></a></li>
<li><a href="#kyoto" data-trip="Kyoto" style="display:grid;gap:.45rem;width:13rem;color:inherit;text-decoration:none"><span style="display:block;aspect-ratio:4/3;border-radius:var(--mv-radius-lg);background:linear-gradient(135deg,oklch(0.8 0.1 350),oklch(0.6 0.14 10))"></span><span style="font-weight:600;font-size:.9rem">Kyoto, Japan</span><span style="font-size:.8rem;color:var(--mv-fg-muted)">Oct 7 to Oct 15 · from $2,890</span></a></li>
<li><a href="#cape-town" data-trip="Cape Town" style="display:grid;gap:.45rem;width:13rem;color:inherit;text-decoration:none"><span style="display:block;aspect-ratio:4/3;border-radius:var(--mv-radius-lg);background:linear-gradient(135deg,oklch(0.8 0.1 200),oklch(0.55 0.12 240))"></span><span style="font-weight:600;font-size:.9rem">Cape Town, South Africa</span><span style="font-size:.8rem;color:var(--mv-fg-muted)">Oct 10 to Oct 18 · from $2,150</span></a></li>
<li><a href="#oaxaca" data-trip="Oaxaca" style="display:grid;gap:.45rem;width:13rem;color:inherit;text-decoration:none"><span style="display:block;aspect-ratio:4/3;border-radius:var(--mv-radius-lg);background:linear-gradient(135deg,oklch(0.82 0.13 90),oklch(0.62 0.16 45))"></span><span style="font-weight:600;font-size:.9rem">Oaxaca, Mexico</span><span style="font-size:.8rem;color:var(--mv-fg-muted)">Oct 12 to Oct 17 · from $980</span></a></li>
<li><a href="#reykjavik" data-trip="Reykjavík" style="display:grid;gap:.45rem;width:13rem;color:inherit;text-decoration:none"><span style="display:block;aspect-ratio:4/3;border-radius:var(--mv-radius-lg);background:linear-gradient(135deg,oklch(0.8 0.08 180),oklch(0.5 0.1 270))"></span><span style="font-weight:600;font-size:.9rem">Reykjavík, Iceland</span><span style="font-size:.8rem;color:var(--mv-fg-muted)">Oct 19 to Oct 24 · from $1,760</span></a></li>
<li><a href="#marrakesh" data-trip="Marrakesh" style="display:grid;gap:.45rem;width:13rem;color:inherit;text-decoration:none"><span style="display:block;aspect-ratio:4/3;border-radius:var(--mv-radius-lg);background:linear-gradient(135deg,oklch(0.78 0.12 40),oklch(0.55 0.13 15))"></span><span style="font-weight:600;font-size:.9rem">Marrakesh, Morocco</span><span style="font-size:.8rem;color:var(--mv-fg-muted)">Oct 23 to Oct 28 · from $1,090</span></a></li>
<li><a href="#queenstown" data-trip="Queenstown" style="display:grid;gap:.45rem;width:13rem;color:inherit;text-decoration:none"><span style="display:block;aspect-ratio:4/3;border-radius:var(--mv-radius-lg);background:linear-gradient(135deg,oklch(0.8 0.1 150),oklch(0.52 0.11 190))"></span><span style="font-weight:600;font-size:.9rem">Queenstown, New Zealand</span><span style="font-size:.8rem;color:var(--mv-fg-muted)">Oct 26 to Nov 5 · from $3,420</span></a></li>
</ul>
</mv-drag-scroll>
</section>
</div>
<script type="module">
const root = document.getElementById("mv-drag-scroll-demo");
const status = root.querySelector("[data-demo-status]");
root.addEventListener("click", (e) => {
const trip = e.target.closest("[data-trip], a[href^='#']");
if (!trip) return;
e.preventDefault();
status.textContent = `Opened ${trip.dataset.trip ?? trip.textContent}`;
});
</script>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
buttons | boolean | Round previous / next buttons on the hidden sides, each scrolling one visible width (minus the fades). | |
label | string | table caption, else “Scrollable content” | Accessible name of the scroll region (a <caption> inside is used automatically). |
no-hint | boolean | Keeps the fades but removes the “More” hint chips. | |
no-wheel | boolean | Leaves the vertical mouse wheel alone (never turned sideways). | |
no-drag | boolean | Disables grab-drag (e.g. when every item is itself draggable). | |
data-overflow | "start" | "end" | "start end" | "" | Set by the component while the content overflows: the sides that currently hide something (absent when nothing overflows). | |
data-scrolled | boolean | Set once the reader has scrolled (the hint drops its word). | |
data-dragging | boolean | Set during a grab-drag. |
Properties
| Name | 유형 | Description |
|---|---|---|
strings | { label, more, previous, next } | Default texts, for translation: region name, hint word, button names (“Scroll back”, “Scroll forward”). Assignable before the element is defined. |
scroller | HTMLElement | The scrolling element (read-only). |
overflow | { start, end } | Which sides currently hide content (read-only). |
Methods
| Name | Description |
|---|---|
page(direction) | Scrolls one visible width toward the end (1) or the start (-1), smoothly unless motion is reduced. |
Events
| Name | Description |
|---|---|
mv-overflow | When the hidden sides change. detail = { start, end }. |
Content structure
| Name | Description |
|---|---|
(default) | The wide content. It is wrapped in .mv-drag-scroll-viewport; a single child that already scrolls sideways (overflow-x: auto) is used as is, or provide .mv-drag-scroll-viewport yourself (frameworks that own their children). |
CSS classes
| Name | Description |
|---|---|
mv-drag-scroll-viewport | The scrolling box (generated, detected or yours). |
mv-drag-scroll-hint / -button | Generated hint chips (decorative) or paging buttons, with data-side="start|end". |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-drag-scroll-fade | 3rem | Width of the edge fades. |
--mv-drag-scroll-hint-top | 50% | Vertical position of the hint chips and buttons. |
--mv-drag-scroll-radius | var(--mv-radius-md) | Corner radius of the region and its focus ring. |
Accessibility
While the content overflows, the scrolling box becomes a focusable region (tabindex=0, role=region) named by the label attribute, a table <caption> or “Scrollable content”, so keyboard users scroll it with the arrow keys and screen readers announce what it is; when nothing overflows it is a plain box with no extra tab stop. The fades and hint chips are decorative (aria-hidden, pointer-events: none). The optional buttons are real named <button>s with aria-controls, kept out of the Tab order because the region itself scrolls with the keyboard; if one hides while focused, focus moves to the region. Grab-drag is mouse-only, starts after 6 px, and only the click that ends a real drag is cancelled; a press held still for a moment keeps native text selection and link or image drag; form fields, contenteditable, draggable="true" and [data-no-drag] never start a drag, and double-click or Shift selection is untouched. Touch, pen and trackpad scrolling are never intercepted. The wheel is turned sideways only for a real mouse wheel, after the pointer moved over the region (not when the page scrolls it under a still cursor), when nothing under the pointer scrolls vertically, and it goes back to the page at either end. Standard grab / grabbing cursors, only under (hover: hover) and (pointer: fine). Reduced motion (system or data-motion="reduce"): no momentum, no glide, no nudge. Forced colors: no mask, bordered chips, Highlight focus. Right-to-left layouts supported. Without JavaScript the content still scrolls. Known limits: horizontal only (vertical overflow stays native); keyboard scrolling is the browser's own (arrows scroll by a step, Home and End follow the browser, not a page of items); the trackpad and mouse wheel are told apart by heuristics on the wheel deltas, so an unusual mouse driver can be taken for a trackpad and simply scroll natively.