
Custom SVG Brand Pointer with the CSS cursor url() Property and a Keyword Fallback
Published
6 hand-coded CSS custom cursor implementations — from a zero-JS cursor: url() swap to modern interactive followers. Covers the full spectrum agency + Awwwards-style portfolio sites ship: a pure-CSS SVG brand pointer with a keyword fallback, a smooth glowing cursor follower on requestAnimationFrame lerp, a magnetic blend cursor that expands on hover via :has() + mix-blend-mode, custom grab/grabbing hand cursors for a drag-to-scroll carousel with Pointer Events, a "View / Play / Read" badge that follows the mouse over a gallery via data-attribute delegation, and a magnifier zoom lens with background-position math. Every custom cursor is guarded behind @media (hover:hover) and (pointer:fine) so touch/stylus users keep the native caret; prefers-reduced-motion respected on every animated follower. Cursor art is inline SVG data-URIs — self-contained, zero network requests, always with a mandatory keyword fallback so it degrades gracefully. Scoped under .ccs-NN for no-collision pasting, framework-agnostic.
Related10 CSS Dark Mode Toggles30 CSS Hover Effects22 CSS Glassmorphism

Published

Published

Published

Published

Published

Published
Working on a specific product? Match your surface to the right pattern here — every row maps a real project constraint to the exact demo above that solves it. Six situations, six answers.
| Your situation | Pick | Why |
|---|---|---|
| You want a branded pointer on a marketing site with zero JavaScript | SVG Brand Pointer | Pure CSS `cursor: url()` swap with a keyword fallback. No listener, no follower layer, no perf cost. The safest starting point for any site that just wants a brand mark on the pointer. |
| You're building an Awwwards-style portfolio, agency site, or premium landing page | Smooth Glowing Follower | The signature portfolio effect — crisp dot locked to the pointer plus a trailing glow ring. requestAnimationFrame lerp keeps it smooth at any frame rate. Reads as premium immediately. |
| You need the cursor to expand as the user hovers buttons and links | Magnetic Blend Cursor | mix-blend-mode inverts against whatever it covers so contrast is automatic; the hover-scale is pure CSS via `:has()`, so JavaScript only moves the blob and never toggles hover state. |
| You have a drag-to-scroll carousel or gallery that needs an obvious drag affordance | Grab / Grabbing Drag Cursor | Custom open-hand cursor on hover, closed-hand while dragging. Falls back to native `grab`/`grabbing` keywords everywhere. Ships with a working Pointer Events drag-to-scroll handler so the cursor tells the truth. |
| You're building an e-commerce grid, portfolio index, or content gallery | View Badge That Follows the Mouse | 'View' / 'Play' / 'Read' pill glides with the pointer per hovered card, driven by a data-attribute per card. One handler powers the entire grid; label swaps automatically as the pointer moves between cards. |
| You're building a product image viewer, lightbox, or design-tool zoom feature | Magnifier / Zoom Lens Cursor | Native `zoom-in` cursor + a real circular lens showing a 2.5× close-up of exactly what's under it. Uses only `background-position` math — no canvas, no image duplication headaches. |
The invisible details that separate a working custom cursor from a polished one. Every tip below applies to any of the 6 demos above and covers the accessibility + performance disciplines every senior UI engineer ships by default.
Why: A custom cursor is meaningless on a touchscreen and can leave stylus/touch users with a stuck or invisible pointer. Scope every override behind a capability query so only mouse/trackpad users get it.
@media (hover: hover) and (pointer: fine) {
.stage { cursor: url("cursor.svg") 4 4, auto; } /* mouse/trackpad only */
}Why: cursor: url() with no trailing keyword is INVALID and the whole declaration is dropped. List rasters before SVG before the required keyword so every browser resolves something.
.link { cursor: url("hand.png") 12 4, /* old engines */
url("hand.svg") 12 4, /* modern */
pointer; /* required fallback */ }Why: Trailing dots, magnetic blobs and lens motion are 'animation from interaction' (WCAG 2.3.3). Detect the preference and restore the native cursor instead of animating.
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
/* …and in CSS: */
@media (prefers-reduced-motion: reduce) {
.stage { cursor: auto; } .follower { display: none; }
}Why: cursor:none across a whole page leaves users unable to see where they are pointing. Only apply it to the exact area where a visible custom layer is rendered, and keep that layer pointer-events:none.
.stage { cursor: none; } /* only here */
.follower { pointer-events: none; } /* never eats clicks */Why: Updating left/top each frame forces layout and paints; transform is handled by the compositor. It's the difference between a buttery follower and a janky one.
el.style.transform =
`translate(${x}px, ${y}px) translate(-50%, -50%)`; /* GPU-friendly */Why: Drag-to-pan and hover badges are enhancements, not the only path. Every interactive element must stay a focusable link/button with a visible :focus-visible ring for keyboard and screen-reader users.
.card:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }cursor CSS property accepts a comma-separated list: one or more url() images followed by a mandatory keyword fallback like auto, pointer, or grab. The browser walks the list top-to-bottom and uses the FIRST cursor it can successfully load, so cursor: url("loupe.svg") 12 12, url("loupe.png") 12 12, zoom-in means “try the SVG first, fall back to the PNG, and if both fail use the native zoom-in cursor.” The two trailing numbers after each URL are the hotspot — the exact pixel inside the image that counts as the click point. For an arrow tip that's usually 4 3 (upper-left tip); for a centered magnifier or crosshair it's the image's center. Omitting the hotspot leaves the browser to guess, which makes clicks feel misaligned by a few pixels — always set it explicitly. The keyword at the end is REQUIRED — cursor: url("foo.svg") with no trailing keyword is invalid CSS and the entire declaration is dropped, giving you the default arrow. This is the single most common bug developers hit when adding custom cursors. Also worth knowing: browsers cap custom cursor images around 128×128px (many are stricter — Chrome ignores anything above 128px, older Safari caps around 32×32) and silently reject images larger than that, falling through to your keyword. Keep custom cursor art small (16-32px is the sweet spot) and use an SVG data-URI for zero network cost. Every demo in this collection uses inline SVG data-URIs with keyword fallbacks, so the whole cursor ships inside your CSS with no extra requests.requestAnimationFrame loop. On pointermove you store the latest target coordinates mx, my. Then every animation frame, you nudge the ring's current position rx, ry toward the target by a fraction: rx += (mx - rx) * 0.15. That 0.15 factor is the ease strength — 0.1 is dreamy and slow, 0.2 is snappy, 0.15 is the sweet spot most portfolio sites use. The dot uses the target coordinates directly so it feels locked; the ring uses the eased position so it drifts behind. Both layers position via transform: translate(<x>px, <y>px) translate(-50%, -50%) — the first translate places the layer at the coordinate, the second recentres it on that coordinate. NEVER animate left/top in the loop; that triggers layout every frame and destroys Interaction to Next Paint. transform is handled by the compositor and stays at 60fps even on mid-tier Android. The native cursor is hidden with cursor: none ONLY on the stage where the replacement layers live — never site-wide, because a hidden cursor with no replacement leaves users unable to see where they're pointing. Every follower needs pointer-events: none so it never intercepts clicks meant for the underlying content, plus prefers-reduced-motion and @media (hover: hover) and (pointer: fine) guards so touch users and motion-sensitive users get the native cursor. Demo 02 in this collection ships the full ~20-line vanilla implementation with all four guards; drop it into any stack (React, Vue, Svelte, Astro, plain HTML) — no library needed.:has() selector shipped in 2023, a parent could not react to a child's state — if you wanted the cursor blob to grow when the user hovers a button, JavaScript had to attach hover listeners to every button, toggle a class on the blob or its parent, and clean up on unhover. That's fragile and janky, especially on grids with dozens of interactive elements. :has() flips the direction: a selector like .stage:has([data-magnetic]:hover) .blob { scale: 3.4 } reads as “when the stage contains any hovered element with a data-magnetic attribute, grow the blob to 3.4× scale.” The parent restyles itself based on the descendant's state — 100% CSS, no JavaScript, no class toggling, no cleanup. Demo 03 in this collection uses this exact pattern: JavaScript only moves the blob on pointermove; the entire hover-expand behavior lives in a single CSS rule. Combined with mix-blend-mode: difference on the blob, the effect reads as “the cursor wraps every button in a bright focus ring” automatically — the blend inverts against whatever it covers, so contrast is guaranteed regardless of button color. Browser support: :has() shipped in Safari 15.4 (March 2022), Chrome 105 (August 2022), Firefox 121 (December 2023). For older browsers, wrap the rule in @supports selector(:has(*)) and provide a JS class-toggle fallback for the <2% of traffic that predates it. This is one of the biggest quality-of-life improvements in modern CSS — most sites that used to ship 50+ lines of JavaScript for hover states can now do it in one line of CSS.cursor: url("open-hand.svg") 12 6, grab for its default state, and an .is-dragging class (added on pointerdown, removed on pointerup) swaps to cursor: url("closed-hand.svg") 12 10, grabbing. Both custom images fall back to the standard grab/grabbing keywords the browser ships natively, so even without the SVG the affordance is correct. Text and images inside the track get user-select: none and -webkit-user-drag: none so dragging pans the track instead of selecting text or ghost-dragging an image (WebKit's default). The drag-to-scroll half uses Pointer Events (not mouse events — pointer events unify mouse, touch, and stylus in one API). On pointerdown you record the pointer's start X and the track's current scrollLeft, call setPointerCapture(pointerId) so the drag survives even if the cursor leaves the track element, and add the .is-dragging class. On pointermove you set scrollLeft = startLeft - (currentX - startX). On pointerup you release the pointer capture and remove the class. This is a 15-line handler; no drag library needed. The whole thing is wrapped in @media (hover: hover) and (pointer: fine) so touch users get native momentum scrolling (which is superior to a JS reimplementation on mobile). Accessibility caveat: dragging must NEVER be the ONLY way to move — always provide keyboard-focusable buttons or make the track's children real focusable elements so keyboard and screen-reader users can navigate without touching a mouse.data-cursor-label: <a data-cursor-label="View">...</a> for case studies, data-cursor-label="Play" for videos, data-cursor-label="Read" for articles. A single badge element sits at the grid level with pointer-events: none so it never blocks clicks. A single delegated pointerover handler on the grid reads the hovered card's label via e.target.closest('[data-cursor-label]').dataset.cursorLabel and writes it into the badge's textContent. The closest() call is critical — the pointer is usually over an inner child element (an image, a heading), not the card itself, so raw e.target would miss the label. A separate pointermove handler positions the badge with transform: translate() (with a light lerp for smoothness); pointerout hides it. Show/hide is a CSS transition on opacity and scale — the badge springs from 0.6 to 1 for a lively pop. Because the label comes from the DOM via data-attribute, adding a card with a new verb needs zero JavaScript changes — just add data-cursor-label="Buy" or whatever new label makes sense. This is the pattern Awwwards-style portfolio sites, Behance, Dribbble, and every premium e-commerce grid uses to guide clicks with typography instead of icons.cursor: zoom-in, zoom-out, and crosshair keywords for free. Layer a custom SVG loupe in front with cursor: url("loupe.svg") 12 12, zoom-in so it looks branded but degrades to the native keyword. Second, the payoff is a lens element with three properties: position: absolute, pointer-events: none (so it never intercepts the pointermove events you need from the image), and background-image set to the SAME photo the visible image uses, but with background-size: 250% so it's rendered at 2.5× scale. On pointermove you compute the pointer's position as a percentage of the image dimensions (px = (e.clientX - rect.left) / rect.width * 100) and set the lens's background-position to that same percentage. Because background-position in percentage terms is defined relative to the difference between container size and image size (the “overflow”), mapping pointer-% to position-% keeps the zoomed layer perfectly registered with the original — the lens always shows exactly what's underneath. The lens itself is positioned via transform: translate() to the pointer coordinates, with translate(-50%, -50%) to center it on the pointer. Zoom depth is one number: change background-size to 400% for deeper zoom, 200% for gentler. Keep pointer-events: none on the lens — without it the lens sits between the pointer and the image and swallows the events you need. Touch guard applies as always: on phones, dropping the lens and showing the plain image is the right behavior.@media (hover: hover) and (pointer: fine). hover: hover matches devices that can hover an element (mouse, trackpad) but not touch or stylus (which can't hover meaningfully). pointer: fine matches devices with precise pointers (mouse, trackpad) but not coarse pointers (finger, stylus). Both queries together mean “only apply this to mouse/trackpad users” — touch users keep the native caret and never end up with a stuck or invisible cursor. Second, reduced-motion respect: animated followers (Demo 02, 03, 05) are “animation from interaction” per WCAG 2.3.3 and should honor @media (prefers-reduced-motion: reduce). Every animated cursor in this collection detects it and restores the native cursor via { cursor: auto } + hides the follower layer via display: none. Third, never hide the native cursor without a replacement: cursor: none applied site-wide leaves users unable to see where they're pointing. Scope it to the EXACT area where a visible custom layer is rendered, and give that layer pointer-events: none so it never eats clicks. Fourth, keep interactive targets reachable without the cursor: drag-to-scroll carousels (Demo 04) and hover-badge galleries (Demo 05) are enhancements — every card must stay a real focusable link or button with a visible :focus-visible ring, 44×44px minimum touch target (WCAG 2.5.5), and semantic HTML that screen readers can navigate. Two more polish items: give decorative follower/badge layers aria-hidden="true" so screen readers don't announce them; give form inputs and text areas cursor: text back explicitly if you set a global custom cursor, so text-editing affordance never breaks. Every demo in this collection ships all four disciplines by default — copy the code, keep the guards, ship.:has() hover pattern (Demo 03 uses 2023+ CSS to eliminate 50 lines of hover-state JavaScript), and the honest per-demo accessibility discipline (many library cursors ship without touch guards or reduced-motion respect and require you to layer them yourself). Accessibility floor — every demo here ships with capability guards, reduced-motion respect, focus-visible rings, aria-hidden decorative layers, and 44px touch targets by default. Library cursor components vary; some have solid a11y, some don't. That said — if you're already on React and Framer Motion, adding a follower cursor via motion.div + useMotionValue is 5 lines and reasonable. If you want to understand what a cursor system actually IS (not what the wrapper hides), or if you're framework-agnostic, copy these 6 demos and ship.className instead of class), a Vue single-file component's template, a Svelte component, an Astro island, or a SvelteKit page — the CSS ships as a scoped module, an inline <style> block, or a Tailwind arbitrary-value paste. React — use useEffect to attach the pointer handlers + start the requestAnimationFrame loop, and return a cleanup that cancels the RAF. React StrictMode double-mount is safe on every demo — cleanup functions properly cancel RAF loops. Vue 3 — use onMounted to attach handlers, onBeforeUnmount to clean up. Svelte / SvelteKit — onMount + onDestroy. Astro islands — wrap as client:load or client:visible since the cursor needs client-side interaction. Next.js App Router — mark the component 'use client'; there's no SSR concern because the cursor logic never runs on the server (all guarded behind capability queries that only match in a browser). SSR compatibility: every demo renders correctly on the server without hydration flash because the initial state has no follower visible (opacity: 0, revealed only on pointer enter). Content-Security-Policy: every demo works under strict CSP (script-src 'self' without 'unsafe-inline') because no demo uses inline onclick or inline <script> handlers — the JS runs from external files that the CSP allowlist can permit. Tailwind users: the CSS translates cleanly. cursor: url("...") becomes cursor-[url('...')] arbitrary value. pointer-events: none becomes pointer-events-none. mix-blend-mode: difference becomes mix-blend-difference. transform: translate() becomes translate-x-[...] translate-y-[...]. Every demo has been mentally tested at 320px / 600px / 1200px viewport widths per this site's responsive-first contract — the code you copy is the code that ships to your users at every width, and the touch guards mean mobile users automatically get the native cursor behavior.30 hand-coded CSS badges for status indicators, notifications, membership tiers, live-data displays, and SEO / DevOps / financial dashboards — upload progress, typing indicator, transit line status, Core Web Vitals, ECG heartbeat, CI/CD build pipeline, countdown ring, live price ticker, keycap shortcut, wax seal, conference lanyard, and holographic collectibles. Copy-paste HTML and CSS, WCAG 2.2 accessible, MIT licensed.
20 hand-coded CSS banner and alert bars for e-commerce storefronts, SaaS dashboards, marketing sites, and compliance-bound products. Covers GDPR cookie consent with a real preferences panel, promo bars with countdown timers, free-shipping threshold progress, app-install smart banners, maintenance and incident status bars, newsletter signup bars, age verification gates, limited-stock urgency banners, live event announcements, browser upgrade notices, geo and currency switchers, sticky top announcement bars, bottom cookie bars, inline form validation alerts, icon-aligned alert banners, left-border accent alerts, diagonal stripe promos, full-width hero banners, animated gradient border alerts, and a text-wrapping laboratory that shows five strategies for the unbreakable string that breaks more banners than anything else. Every bar is scoped under a .ba-NN prefix for no-collision pasting, guards prefers-reduced-motion, carries the correct aria-live and role semantics, and ports unchanged to React, Vue, Svelte, Astro, Next.js and Tailwind.
25 hand-coded CSS blockquote designs for SaaS testimonials, editorial pull quotes, documentation callouts, and developer engineering blogs: large decorative quotation marks with ::before / ::after glyphs, Tailwind CSS blockquote component, pure CSS callout / admonition boxes for Docusaurus / Mintlify / Nextra documentation, responsive center-aligned quote banners, modern minimalist left-border editorial style, animated CSS gradient border, classic left border, brutalist high-contrast, glassmorphism frosted card, glowing neon border, article pull quote with text-wrap, speech bubble chat-style, thick vertical accent rail, customer testimonial cards with avatar and star rating, inline highlighted text, marker highlight underline, floating drop-shadow, aurora gradient background, Twitter / X card style, multi-column newspaper editorial, IDE code-comment style, Markdown-compatible defaults, JavaScript testimonial quote slider, expandable read-more, and one-click copy button. 22 Pure CSS + 3 Light JS (slider, expand, copy). Every design scoped under .bq-NN prefix for no-collision pasting, prefers-reduced-motion guarded per WCAG 2.3.3, WCAG 2.2 AA accessible, framework-agnostic (React, Vue, Svelte, Astro, Next.js, Nuxt, Remix, SvelteKit).