Skip to content

Latest commit

 

History

16 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 

Repository files navigation

Smart Cursor

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

Install

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.

From a CDN

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.0

For 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>

From GitHub Pages

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.

Self-hosted

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>

Do not link to raw.githubusercontent.com

<!-- 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.

Placement

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.

Configure

Colour

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.

Size and feel

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.011, 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>.

The catch

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

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.

When the tokens are read

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.

Tell it what is interactive

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.

Turning it on

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();        // → boolean

setEnabled 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.

Theme switches

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.

What it handles for you

  • Touch devices — under (hover: none) the overlays are hidden, the native cursor is restored, and setEnabled(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 on fullscreenchange and back out on exit.
  • Pointer leaving the window — the overlays fade out on mouseout with no related target, and on window blur (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 documentsiframe, embed and object get the native cursor back for as long as the pointer is inside one. Their content is a separate document: cursor: none does 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 — elementFromPoint costs 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.

A drawn ring

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.

CSS notes

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.

Performance

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's e.target — no elementFromPoint polling 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 translate3d transform (compositor-only) rather than left/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.

Browser support

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.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages