A custom pointer for the web: a glowing dot plus a ring that morphs onto whatever it hovers.
- Idle — the ring is a small circle trailing the pointer.
- Over an interactive element — the ring animates onto that element's outline, matching its position, size and corner radius.
- Over a text surface — the ring fades out and the dot becomes a caret.
It picks its own colours from the background it sits on (dark ring on light surfaces, light ring on dark ones), so it stays visible across themes without per-page configuration.
Two files, no build step, no dependencies.
https://github.com/N0m4n904/smartcursor
Both files must be loaded: the stylesheet carries the configuration tokens the script reads back, so the script alone will silently fall back to its built-in defaults.
jsDelivr serves any file in a public GitHub repository with the right MIME type, so no hosting setup is needed — paste these two tags into your page:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/N0m4n904/smartcursor@v1.0.0/smartcursor.css">
<script src="https://cdn.jsdelivr.net/gh/N0m4n904/smartcursor@v1.0.0/smartcursor.js" defer></script>The part after @ is a git tag, branch or commit SHA:
| Reference | URL fragment | Behaviour |
|---|---|---|
| Tag | @v1.0.0 |
Immutable, cached permanently. Use this in production. |
| Branch | @main |
Follows the branch, but jsDelivr caches it for up to 12 hours — an edit will not appear immediately. |
| Commit | @a1b2c3d |
Immutable, pins one exact revision. |
| (omitted) | smartcursor/smartcursor.js |
Resolves to the latest tag. Convenient, but a future release can change the cursor under you. |
Tag a release before pointing a live site at it:
git tag v1.0.0
git push origin v1.0.0For a third-party CDN it is worth pinning the file contents as well as the version. Compute the hashes once, after pushing:
openssl dgst -sha384 -binary smartcursor.js | openssl base64 -A<script src="https://cdn.jsdelivr.net/gh/N0m4n904/smartcursor@v1.0.0/smartcursor.js"
integrity="sha384-<hash>" crossorigin="anonymous" defer></script>If Pages is enabled for the repository (Settings → Pages, source main), the
files are also served correctly from:
<link rel="stylesheet" href="https://n0m4n904.github.io/smartcursor/smartcursor.css">
<script src="https://n0m4n904.github.io/smartcursor/smartcursor.js" defer></script>This always reflects the current branch, with no CDN cache in front of it — handy while developing, less predictable for a live site.
Copy both files into your own site and reference them by path. This is the only option that works for a private repository:
<link rel="stylesheet" href="/smartcursor.css">
<script src="/smartcursor.js" defer></script><!-- Does not work -->
<script src="https://raw.githubusercontent.com/N0m4n904/smartcursor/main/smartcursor.js"></script>Raw URLs are served as text/plain with X-Content-Type-Options: nosniff, so
the browser refuses to execute the script and ignores the stylesheet. The
failure is quiet — the page loads, the cursor simply never appears. Use jsDelivr
or Pages instead.
The script appends its overlays to <body> as soon as it runs, so it must not
run before the body exists — use defer (as in every example above) or place
the <script> tag at the end of <body>.
Load smartcursor.css before any stylesheet of your own that overrides its
tokens, since the defaults are declared on :root.
The dot reads two custom properties off :root and picks whichever suits the
background it is currently over:
:root {
--brand: #a60430; /* used on light backgrounds */
--brand-light: #ff7a9c; /* used on dark backgrounds */
}Both should be readable against the surface they are named for. Leave them alone
and the dot is near-black on light surfaces and near-white on dark ones —
unbranded, but visible on both. The defaults are declared in smartcursor.css
like every other token; the script only falls back to its own copy of them if
the stylesheet was never loaded.
The ring is toned the same way, and carries a halo in the opposite tone:
| Property | Default | Controls |
|---|---|---|
--sc-ring-on-light |
rgba(40, 40, 40, 0.8) |
ring border over a light surface |
--sc-ring-on-dark |
rgba(255, 255, 255, 0.85) |
ring border over a dark surface |
--sc-halo-on-light |
rgba(255, 255, 255, 0.55) |
halo over a light surface |
--sc-halo-on-dark |
rgba(0, 0, 0, 0.45) |
halo over a dark surface |
--sc-ring-halo |
1px |
halo thickness each side of the border; 0 removes it |
The halo exists because the cursor cannot see everything it sits on. Luminance is
measured by walking up from the hovered element to the first background colour —
and an image, a video, a canvas or a CSS gradient paints no background colour, so
the walk goes straight past it to the surface behind. A light photo on a dark page
is therefore read as dark, and a ring toned for that page disappears on top of it.
Sampling the pixels instead is not an option: a cross-origin image taints the
canvas, and a readback per hover is exactly the forced work the animation loop is
built to avoid. So the ring is drawn halo | border | halo, which keeps an edge
against any surface, measured or not.
Every dimension is a custom property, declared once at the top of
smartcursor.css. There are no geometry constants left in the JavaScript — it
reads the three values it animates back out of :root — so retuning the cursor
means overriding these and nothing else:
| Property | Default | Controls |
|---|---|---|
--sc-dot-size |
6px |
diameter of the dot |
--sc-dot-glow |
6px |
inner glow radius; the outer layer is 3× this |
--sc-ring-size |
34px |
diameter of the idle ring |
--sc-ring-width |
2px |
ring border thickness |
--sc-ring-opacity |
0.65 |
ring opacity when idle |
--sc-ring-opacity-morph |
0.9 |
ring opacity when morphed onto an element |
--sc-pad |
5px |
gap left between a hovered element and the ring |
--sc-ease |
0.3 |
how far the ring closes on its target each frame |
--sc-catch |
6px |
at most how far past an element the ring swings as it takes hold |
--sc-catch-time |
0.25 |
at most how many seconds that swing lasts; 0 on either arrives without one |
--sc-caret-width |
2px |
thickness of the caret over text |
--sc-caret-scale |
1.2 |
caret height as a multiple of the text's font size |
--sc-dot-z |
99999 |
the height the dot is drawn at |
--sc-ring-z |
99998 |
the height the ring rests at while it hugs nothing |
Override them from a stylesheet loaded after smartcursor.css:
:root {
--sc-ring-size: 44px; /* a larger, looser ring */
--sc-pad: 8px;
--sc-ease: 0.18; /* … that trails further behind the pointer */
}--sc-ease is a rate, not a duration: 1 snaps instantly, lower values trail
more. It is clamped to 0.01–1, since 0 would freeze the ring in place.
Declare these on :root. Setting one on an individual element has no effect —
the script resolves them from the document root, so a component cannot request
its own ring padding.
Lengths may use any CSS unit, not just px — --sc-ring-size: 2.5rem works.
Relative units are resolved by the browser at read time, with em and %
resolving against <body>.
Arriving is otherwise not something you see. The lerp closes on a target in a handful of frames, and a page that has asked for a drawn ring spends longer than that fading the drawing in — so the line appears already at rest, on a shape that stopped moving before it was visible. That reads as clipping onto the element rather than catching it, and it is why leaving looks animated while arriving does not: on the way out the line is fully there and you watch it go.
So the ring is given somewhere to arrive from. It swings past the element and settles back onto it on a half turn of a sine — nothing at the moment it sets off, most of it as the ring arrives, and nothing again once it has settled, so the lerp has no discontinuity to snap through at either end. Crossing straight from one element to the next is a catch too, and gets the same movement.
Both tokens are ceilings rather than amounts. A swing is measured against what it swings around: six pixels is a flourish around a card and a balloon around a link inside that card, and a quarter of a second spent settling onto a line of text feels like hesitation where the same quarter second on a card reads as weight. So no swing is worth more than an eighth of the shorter side of the element, and the time shrinks in proportion with it — small things are caught briskly, large ones get the whole movement.
It rides on top of --sc-pad, which means it costs no extra machinery: the
target box each frame is simply the element plus a gap that is briefly wider
than usual. Set either token to 0 for the old behaviour.
--sc-catch is where the target goes, so the ring itself realises a little
less of it — it is lerping towards a gap that is already easing back, and it
arrives late by however slow --sc-ease is. At the default ease a 6px catch on
something large enough to earn it shows as roughly 4px of daylight at its
widest. Raise the token, or the ease, if you want more of it.
The caret is not a fixed size — it takes its height from the text it sits in,
as font-size × --sc-caret-scale. An 11px field gets a 13px caret and a 32px
one gets a 38px caret, and the dot's height transition animates between them as
the pointer crosses from one to the other.
The measurement is taken from the element actually under the pointer, not
from the input/textarea/[contenteditable] that TEXTUAL matched. Inside a
rich editor the pointer may be over a heading or a code span with its own size,
and that local size is what a native caret would take.
--sc-caret-width is deliberately not scaled — native carets stay thin
regardless of text size. Override it if you want otherwise.
The script reads these at startup and again on smartCursor.refresh(), never
per frame — resolving custom properties forces a style recalculation, which the
animation loop stays clear of. So a theme that also retunes sizes only needs the
same refresh() call it already makes for colour; the ring animates to the new
geometry rather than jumping to it.
The two selectors near the top of smartcursor.js are the main thing you will
want to edit. They decide which of the three modes applies:
const INTERACTIVE =
'a, button, select, label, summary, [role="button"], .card, .server-card, .tab, .link-btn';
const TEXTUAL =
'input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="reset"]),' +
'textarea, [contenteditable="true"], .xterm';INTERACTIVE is what the ring morphs onto. Replace the project-specific classes
(.card, .server-card, .tab, .link-btn, and .xterm in TEXTUAL) with
your own — they are examples, not part of the library.
Editing the file is not an option when you load it from a CDN, so both can be set at runtime instead:
smartCursor.setInteractive('a, button, [role="button"], .tags li');
smartCursor.setTextual('input, textarea, [contenteditable="true"]');Each replaces the whole list, so include what you still want. Call either with no argument to return to the built-in one. An unusable selector throws at the call rather than later from inside a mouse handler, where the stack would not say who set it.
TEXTUAL wins over INTERACTIVE: anything matching it gets the caret. It is
meant for typable surfaces. Clickable inputs — checkboxes, radios, and the
button-like input types — are excluded on purpose so they keep the ring.
The ring targets the innermost match, so a card containing its own buttons
gives each button a snug ring rather than outlining the whole card. Both overlays
are pointer-events: none, so clicks always pass through to your page.
It is off by default, and stays off until something enables it. Loading the files alone changes nothing on the page — this is deliberate: replacing the system cursor is a preference, not something to impose on every visitor.
Enable it through the global API:
smartCursor.setEnabled(true); // turn on and remember the choice
smartCursor.setEnabled(false); // turn off and remember the choice
smartCursor.isEnabled(); // → booleansetEnabled persists to localStorage under the key smartCursorV2, and that
value is what the script reads on the next page load. Wiring it to a settings
toggle is the whole integration:
<label>
<input type="checkbox" id="cursor-toggle">
Smart cursor
</label>const toggle = document.getElementById('cursor-toggle');
toggle.checked = smartCursor.isEnabled();
toggle.addEventListener('change', () => smartCursor.setEnabled(toggle.checked));Because smartcursor.js runs in an IIFE, window.smartCursor only exists after
the script has executed — read it from your own deferred script or an event
handler, not from inline markup earlier in the page.
The script caches the background luminance it measured per element. After a theme change those caches are stale, so call:
smartCursor.refresh();from wherever you swap themes. It drops the luminance caches and re-reads the size tokens, so a theme may retune geometry as well as colour. This is the one hook you must remember to call; everything else is automatic.
- Touch devices — under
(hover: none)the overlays are hidden, the native cursor is restored, andsetEnabled(true)is forced to off. There is no pointer to replace, so nothing you do can switch it on. - Fullscreen — a fullscreen element renders in the browser's top layer, above
any
body-level overlay, which would leave the page cursor-less. The overlays reparent into the fullscreen element onfullscreenchangeand back out on exit. - Pointer leaving the window — the overlays fade out on
mouseoutwith no related target, and on windowblur(Alt-Tab). - Your own fixed chrome — the ring is drawn at the height of whatever it is hugging rather than permanently on top, so an outline around a card that has scrolled up behind a sticky header stays behind that header, while the header's own links are still outlined above it. See CSS notes.
- Embedded documents —
iframe,embedandobjectget the native cursor back for as long as the pointer is inside one. Their content is a separate document:cursor: nonedoes not apply to it, and no move events from inside it reach this script, so overlays that kept drawing would simply hang at the frame's edge until the pointer came out again. Nothing can paint a custom cursor inside a cross-origin frame — a YouTube or Instagram embed will always show the visitor's own pointer. - The page changing under a resting pointer — clicking something that opens a
modal or closes a drawer produces no
mousemove, and neither does scrolling the page past a pointer that has not moved. The ring would keep hugging what it last saw, riding away with an element that has scrolled out from under the pointer. What is underneath is re-resolved immediately after a click, and on scroll at most once every 100ms —elementFromPointcosts a forced layout each time it is asked, and a pointer that is not moving does not need sixty answers a second about what is beneath it. An element the ring is already hugging is followed by the animation loop instead, which is not throttled with it.
The ring is a bordered box by default. Set --sc-sketch to 1 and it becomes a
drawn line — one that wanders rather than running true — for as long as it is
hugging something. Idle it stays the plain circle: a drawn outline says "this is
the shape of the thing under me", which is only true once there is something
under it.
| Property | Default | Controls |
|---|---|---|
--sc-sketch |
0 |
1 draws the ring instead of bordering it |
--sc-sketch-wiggle |
2px |
how far the line strays from true |
--sc-sketch-rate |
0 |
seconds between redraws; 0 holds a single drawing |
Redrawing is off by default. Swapping between two drawings is what makes
hand-drawn linework live at the size of a card, but a cursor sits under the eye
and the same swap reads as a flicker there — set --sc-sketch-rate to a number
of seconds only if you want it.
The deviations are worked out once, as fractions of the wiggle, and mapped onto whatever rectangle the ring currently is. So the wobble belongs to the ring and holds still while it moves and morphs — rolling fresh numbers every frame would read as noise rather than as a drawn line.
--sc-ring-on-light and --sc-ring-on-dark still colour it, and
--sc-ring-width is still its weight. The halo does not apply: it is a pair of
box shadows, and there is no box to cast them.
smartcursor.css hides the native cursor via html.sc-on, html.sc-on * { cursor: none !important }.
The .sc-on class is added and removed by setEnabled, so the native pointer
comes straight back when the feature is off.
The dot is drawn at --sc-dot-z (99999) always: it is the pointer, and nothing
on a page should cover it. If your own UI stacks above that, raise the token.
The ring rests at --sc-ring-z (99998) and leaves it while it is hugging
something, for the height of the thing it is hugging. An outline traces an
element, so it belongs where that element is — the ring is position: fixed at
the end of body, and left permanently on top it drew over the page's own fixed
chrome the moment a hugged card scrolled up behind a header. The height is read
off the nearest positioned ancestor with a z-index; an element in ordinary
flow has none, and the answer is 0, which still paints the ring over the content
it is tracing but no longer over the chrome. Hug something in the chrome and
the same walk finds the chrome's own height, so a header's links are still
outlined properly.
Two consequences worth knowing. A page that stacks its content deliberately gets
a ring that follows that stack, which is the point. And a page whose overlays sit
inside a stacking context created without a z-index — a transform, a
filter, an opacity below 1 — will see the walk carry on past it to whatever
is above; if that is ever wrong for you, give the container an explicit
z-index, which is the answer the ring is looking for.
Do not add CSS transitions for the ring's transform, width, height or
border-radius. Those are interpolated per frame in JavaScript and a CSS
transition on the same properties will fight the animation. Only paint properties
(opacity, border-color, background-color, box-shadow) transition in CSS.
The ring's tone is switched by a .on-light class the script adds when the
surface under the pointer measures light, so restyling the ring means overriding
.smart-cursor-ring and .smart-cursor-ring.on-light — or, more simply, the
tokens above.
When nothing is happening, nothing happens:
- The loop stops. Once the ring has arrived and whatever is under it has stopped moving, the frame callback is not rescheduled — a tab left open with the pointer parked on it does no work at all. Every event that could change the picture starts it again. Measured over three idle seconds with the pointer resting on the page: 180 frame callbacks before, 0 after; resting on a card the ring is hugging, 180 callbacks and 180 forced layouts before, 23 and 24 after. A pointer actually being moved costs what it always did.
- The hovered element comes from
mousemove'se.target— noelementFromPointpolling and no forced style recalculation on the hot path. - Mode and colour resolution runs only when the hovered element actually changes, with per-element luminance caching, and reads no custom properties: the two dot colours are resolved with the rest of the tokens.
- Position is written as a
translate3dtransform (compositor-only) rather thanleft/top. - The animation snaps to its target below 0.1px and then stops writing styles altogether, so an idle pointer costs no paint work per frame.
The one thing given up for this: an element that moves entirely on its own — under no scroll, no pointer movement and no click — is not followed until the next event. The loop waits for the hovered element's box to hold still for 90 frames before it stops watching, which is far longer than an ordinary hover transition.
Any browser with WeakMap, Element.closest, matchMedia and
requestAnimationFrame — that is, every current engine. The source is plain ES5+
with no modules, so it can be served as-is or concatenated into an existing
bundle.