6 CSS Custom Cursor Styles04 / 06

CSS + JSMIT licensed

Custom Grab and Grabbing Cursors for a Drag-to-Scroll Carousel with SVG Hand Icons

The affordance that tells users 'you can drag this': a custom open-hand cursor over a horizontal card track that switches to a closed grabbing-hand the instant they press down. The cursors are inline-SVG data-URIs mapped to the native grab / grabbing keywords with fallbacks, and a small Pointer-Events drag-to-scroll handler makes the track actually pannable so the cursor tells the truth.

Published

Live Demo
Try it

The code

<section class="ccs-04">
  <div class="ccs-04__wrap">
    <div class="ccs-04__head">
      <h2>Drag to explore</h2>
      <p>Open hand on hover, closed hand while dragging. Grab the track and pan.</p>
    </div>
    <div class="ccs-04__track" data-track tabindex="0" role="group" aria-label="Draggable card carousel">
      <article class="ccs-04__card" style="--i:0"><span>01</span><strong>Aurora</strong><small>Brand system</small></article>
      <article class="ccs-04__card" style="--i:1"><span>02</span><strong>Monolith</strong><small>Editorial</small></article>
      <article class="ccs-04__card" style="--i:2"><span>03</span><strong>Halcyon</strong><small>E-commerce</small></article>
      <article class="ccs-04__card" style="--i:3"><span>04</span><strong>Vellum</strong><small>Portfolio</small></article>
      <article class="ccs-04__card" style="--i:4"><span>05</span><strong>Cobalt</strong><small>SaaS UI</small></article>
    </div>
  </div>
</section>
.ccs-04,
.ccs-04 *,
.ccs-04 *::before,
.ccs-04 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.ccs-04 {
  --bg: oklch(0.17 0.02 285);
  --surface: oklch(0.23 0.03 285);
  --text: oklch(0.96 0.01 285);
  --accent: oklch(0.75 0.15 320);
  --muted: color-mix(in oklab,var(--text) 55%,var(--bg));
  color-scheme: dark;
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 100vh;
  min-height: 100dvh;
  padding: 28px;
  font-family: system-ui,'Segoe UI',sans-serif;
  background: var(--bg);
  color: var(--text);
}

.ccs-04__wrap {
  width: min(620px,100%);
  display: flex;
  flex-direction: column;
  gap: 18px;
}

.ccs-04__head h2 {
  font: 700 22px/1.1 system-ui,sans-serif;
  letter-spacing: -.01em;
}

.ccs-04__head p {
  font: 400 14px/1.5 system-ui,sans-serif;
  color: var(--muted);
}

.ccs-04__track {
  display: flex;
  gap: 14px;
  overflow-x: auto;
  padding: 6px 2px 16px;
  scroll-snap-type: x proximity;
  scrollbar-width: thin;
}

.ccs-04__card {
  scroll-snap-align: center;
  flex: 0 0 180px;
  height: 220px;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: 2px;
  padding: 18px;
  border-radius: 18px;
  border: 1px solid oklch(0.34 0.03 285);
  background: linear-gradient(160deg,oklch(from var(--accent) 0.35 calc(c*0.6) calc(h + var(--i)*22)),var(--surface));
  user-select: none;
  -webkit-user-drag: none;
}

.ccs-04__card span {
  font: 700 12px/1 ui-monospace,Menlo,monospace;
  color: var(--text);
  opacity: .65;
}

.ccs-04__card strong {
  font: 700 19px/1.1 system-ui,sans-serif;
}

.ccs-04__card small {
  font: 500 12.5px/1 system-ui,sans-serif;
  color: var(--muted);
}

.ccs-04__track:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 12px;
}

@media (hover: hover) and (pointer: fine) {
  .ccs-04__track {
    cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3E%3Cg fill='%23ffffff' stroke='%230b0b12' stroke-width='1.4' stroke-linejoin='round'%3E%3Cpath d='M11 14V7.5a1.6 1.6 0 0 1 3.2 0V13m0-1.5a1.6 1.6 0 0 1 3.2 0V13m0-1a1.6 1.6 0 0 1 3.2 0v1m0 0a1.6 1.6 0 0 1 3.2 0v4.2c0 3.4-2.4 6.3-6.4 6.3-2.6 0-4-1-5.6-2.8l-3.4-3.9a1.7 1.7 0 0 1 2.5-2.2z'/%3E%3C/g%3E%3C/svg%3E") 12 6, grab;
  }

  .ccs-04__track.is-dragging {
    cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3E%3Cpath d='M9 15v-2.2a1.5 1.5 0 0 1 3 0V13m0-.5a1.5 1.5 0 0 1 3 0V13m0-.3a1.5 1.5 0 0 1 3 0V13a1.5 1.5 0 0 1 3 0v3.6c0 3.3-2.3 6-6.1 6-2.5 0-3.8-1-5.3-2.7l-2.2-2.6a1.6 1.6 0 0 1 2.4-2.1z' fill='%23ffffff' stroke='%230b0b12' stroke-width='1.4' stroke-linejoin='round'/%3E%3C/svg%3E") 12 10, grabbing;
  }
}
(function () {
  var track = document.querySelector('.ccs-04__track');
  if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return; // touch → native scroll
  var down = false, startX = 0, startLeft = 0;

  track.addEventListener('pointerdown', function (e) {
    down = true; startX = e.clientX; startLeft = track.scrollLeft;
    track.classList.add('is-dragging');
    track.setPointerCapture(e.pointerId);
  });
  track.addEventListener('pointermove', function (e) {
    if (!down) return;
    track.scrollLeft = startLeft - (e.clientX - startX);
  });
  function end(e) {
    if (!down) return;
    down = false; track.classList.remove('is-dragging');
    try { track.releasePointerCapture(e.pointerId); } catch (err) {}
  }
  track.addEventListener('pointerup', end);
  track.addEventListener('pointercancel', end);
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Custom Cursor Style from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Custom Grab and Grabbing Cursors for a Drag-to-Scroll Carousel with SVG Hand Icons
Source: https://codefronts.com/snippets/css-custom-cursor/grab-drag-carousel/

The affordance that tells users 'you can drag this': a custom open-hand cursor over a horizontal card track that switches to a closed grabbing-hand the instant they press down. The cursors are inline-SVG data-URIs mapped to the native grab / grabbing keywords with fallbacks, and a small Pointer-Events drag-to-scroll handler makes the track actually pannable so the cursor tells the truth.
## HTML
```html
<section class="ccs-04">
  <div class="ccs-04__wrap">
    <div class="ccs-04__head">
      <h2>Drag to explore</h2>
      <p>Open hand on hover, closed hand while dragging. Grab the track and pan.</p>
    </div>
    <div class="ccs-04__track" data-track tabindex="0" role="group" aria-label="Draggable card carousel">
      <article class="ccs-04__card" style="--i:0"><span>01</span><strong>Aurora</strong><small>Brand system</small></article>
      <article class="ccs-04__card" style="--i:1"><span>02</span><strong>Monolith</strong><small>Editorial</small></article>
      <article class="ccs-04__card" style="--i:2"><span>03</span><strong>Halcyon</strong><small>E-commerce</small></article>
      <article class="ccs-04__card" style="--i:3"><span>04</span><strong>Vellum</strong><small>Portfolio</small></article>
      <article class="ccs-04__card" style="--i:4"><span>05</span><strong>Cobalt</strong><small>SaaS UI</small></article>
    </div>
  </div>
</section>
```
## CSS
```css
.ccs-04,
.ccs-04 *,
.ccs-04 *::before,
.ccs-04 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.ccs-04 {
  --bg: oklch(0.17 0.02 285);
  --surface: oklch(0.23 0.03 285);
  --text: oklch(0.96 0.01 285);
  --accent: oklch(0.75 0.15 320);
  --muted: color-mix(in oklab,var(--text) 55%,var(--bg));
  color-scheme: dark;
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 100vh;
  min-height: 100dvh;
  padding: 28px;
  font-family: system-ui,'Segoe UI',sans-serif;
  background: var(--bg);
  color: var(--text);
}

.ccs-04__wrap {
  width: min(620px,100%);
  display: flex;
  flex-direction: column;
  gap: 18px;
}

.ccs-04__head h2 {
  font: 700 22px/1.1 system-ui,sans-serif;
  letter-spacing: -.01em;
}

.ccs-04__head p {
  font: 400 14px/1.5 system-ui,sans-serif;
  color: var(--muted);
}

.ccs-04__track {
  display: flex;
  gap: 14px;
  overflow-x: auto;
  padding: 6px 2px 16px;
  scroll-snap-type: x proximity;
  scrollbar-width: thin;
}

.ccs-04__card {
  scroll-snap-align: center;
  flex: 0 0 180px;
  height: 220px;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: 2px;
  padding: 18px;
  border-radius: 18px;
  border: 1px solid oklch(0.34 0.03 285);
  background: linear-gradient(160deg,oklch(from var(--accent) 0.35 calc(c*0.6) calc(h + var(--i)*22)),var(--surface));
  user-select: none;
  -webkit-user-drag: none;
}

.ccs-04__card span {
  font: 700 12px/1 ui-monospace,Menlo,monospace;
  color: var(--text);
  opacity: .65;
}

.ccs-04__card strong {
  font: 700 19px/1.1 system-ui,sans-serif;
}

.ccs-04__card small {
  font: 500 12.5px/1 system-ui,sans-serif;
  color: var(--muted);
}

.ccs-04__track:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 12px;
}

@media (hover: hover) and (pointer: fine) {
  .ccs-04__track {
    cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3E%3Cg fill='%23ffffff' stroke='%230b0b12' stroke-width='1.4' stroke-linejoin='round'%3E%3Cpath d='M11 14V7.5a1.6 1.6 0 0 1 3.2 0V13m0-1.5a1.6 1.6 0 0 1 3.2 0V13m0-1a1.6 1.6 0 0 1 3.2 0v1m0 0a1.6 1.6 0 0 1 3.2 0v4.2c0 3.4-2.4 6.3-6.4 6.3-2.6 0-4-1-5.6-2.8l-3.4-3.9a1.7 1.7 0 0 1 2.5-2.2z'/%3E%3C/g%3E%3C/svg%3E") 12 6, grab;
  }

  .ccs-04__track.is-dragging {
    cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3E%3Cpath d='M9 15v-2.2a1.5 1.5 0 0 1 3 0V13m0-.5a1.5 1.5 0 0 1 3 0V13m0-.3a1.5 1.5 0 0 1 3 0V13a1.5 1.5 0 0 1 3 0v3.6c0 3.3-2.3 6-6.1 6-2.5 0-3.8-1-5.3-2.7l-2.2-2.6a1.6 1.6 0 0 1 2.4-2.1z' fill='%23ffffff' stroke='%230b0b12' stroke-width='1.4' stroke-linejoin='round'/%3E%3C/svg%3E") 12 10, grabbing;
  }
}
```

## JavaScript
```js
(function () {
  var track = document.querySelector('.ccs-04__track');
  if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return; // touch → native scroll
  var down = false, startX = 0, startLeft = 0;

  track.addEventListener('pointerdown', function (e) {
    down = true; startX = e.clientX; startLeft = track.scrollLeft;
    track.classList.add('is-dragging');
    track.setPointerCapture(e.pointerId);
  });
  track.addEventListener('pointermove', function (e) {
    if (!down) return;
    track.scrollLeft = startLeft - (e.clientX - startX);
  });
  function end(e) {
    if (!down) return;
    down = false; track.classList.remove('is-dragging');
    try { track.releasePointerCapture(e.pointerId); } catch (err) {}
  }
  track.addEventListener('pointerup', end);
  track.addEventListener('pointercancel', end);
})();
```

How this works

The cursor half is almost pure CSS. The track gets cursor: url(open-hand) 12 6, grab, and an .is-dragging class (added on pointerdown, removed on pointerup) swaps to cursor: url(closed-hand) 12 10, grabbing. Both custom images fall back to the standard grab/grabbing keywords, so even without the SVG the affordance is correct. Text and images inside get user-select:none and -webkit-user-drag:none so dragging pans instead of selecting text or ghosting an image.

The drag-to-scroll uses Pointer Events: on pointerdown we record the start X and current scrollLeft, capture the pointer, and set the dragging class; on pointermove we set scrollLeft = startLeft − (x − startX); on pointerup we release. Pointer capture means the drag keeps working even if the cursor leaves the track. Everything is wrapped in the pointer-fine guard so touch users get native momentum scrolling and the browser's own panning instead.

Techniques used: custom grab / grabbing SVG cursors with keyword fallback · :active + .is-dragging cursor swap · Pointer Events drag-to-scroll (setPointerCapture) · user-select:none / -webkit-user-drag:none · scroll-snap-type for tidy resting positions · touch guard defers to native momentum scroll

.track            { cursor: url("…open-hand…") 12 6, grab; }
.track.is-dragging{ cursor: url("…closed-hand…") 12 10, grabbing; }
/* pointerdown → add .is-dragging + setPointerCapture; pointermove → set scrollLeft */

Make it yours

  • Add inertia: on pointerup, keep applying the last velocity with a decaying RAF loop for a native 'flick' feel.
  • Swap the hand SVGs for directional arrows (↔) if the content is a comparison slider rather than a gallery.
  • Turn on scroll-snap-type:x mandatory and scroll-snap-align:center for card-by-card snapping.
  • Add left/right buttons for keyboard and screen-reader users so dragging is never the only way to navigate.

Gotchas — read before shipping

  • Dragging must not be the ONLY way to move — always provide keyboard-accessible controls (buttons / focusable items) alongside it.
  • Without user-select:none a drag selects the card text; without -webkit-user-drag:none images ghost-drag in WebKit.
  • Use Pointer Events + setPointerCapture, not separate mouse/touch handlers — one code path, and the drag survives leaving the element.
  • Keep the touch guard: on phones the browser's native scroll is better than a JS reimplementation.

Browser support

ChromeSafariFirefoxEdge
111+16.2+113+111+

Floor set by oklch(), color-mix() as this demo is written. The core technique itself goes back further — Chrome 55+.

grab/grabbing keywords and Pointer Events are universal in evergreen browsers. Custom hand SVGs follow the same Safari-prefers-PNG caveat as Demo 01.

Techniques used in this demo

Search CodeFronts

Loading…