Kosmesis
Components

Smooth Cursor

A viewport-global custom cursor that eases toward the pointer. Purely presentational — no Morphos equivalent.

Installation

npx kosmesis add smooth-cursor
pnpm dlx kosmesis add smooth-cursor
yarn dlx kosmesis add smooth-cursor
bunx kosmesis add smooth-cursor

Copy and paste the following code into your project.

smooth-cursor.tsx
import { StatefulComponent } from "@praxisjs/core";import { Component, Prop, Ref, type Ref as RefType } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { cn } from "@/lib/utils";export interface SmoothCursorProps {  size?: number;  ease?: number;  class?: string;  children?: Children;}/** Writes `transform` directly rather than through `@State`, since the rAF lerp loop needs to keep ticking every frame regardless of pointer movement. */@Component()export class SmoothCursor extends StatefulComponent {  @Prop() size = 24;  @Prop() ease = 0.15;  @Prop() class?: string;  @Prop() children?: Children;  @Ref<HTMLDivElement>()  elRef!: RefType<HTMLDivElement>;  private _target = { x: 0, y: 0 };  private readonly _current = { x: 0, y: 0 };  private _frame = 0;  private readonly _handlePointerMove = (event: PointerEvent) => {    this._target = { x: event.clientX, y: event.clientY };  };  private readonly _tick = () => {    this._current.x += (this._target.x - this._current.x) * this.ease;    this._current.y += (this._target.y - this._current.y) * this.ease;    const el = this.elRef.current;    if (el) el.style.transform = `translate(${String(this._current.x)}px, ${String(this._current.y)}px) translate(-50%, -50%)`;    this._frame = requestAnimationFrame(this._tick);  };  onMount(): void {    window.addEventListener("pointermove", this._handlePointerMove);    this._frame = requestAnimationFrame(this._tick);  }  onUnmount(): void {    window.removeEventListener("pointermove", this._handlePointerMove);    cancelAnimationFrame(this._frame);  }  render() {    return (      <div        ref={this.elRef}        data-slot="smooth-cursor"        class={cn("pointer-events-none fixed top-0 left-0 z-100", this.class)}        style={{ width: `${String(this.size)}px`, height: `${String(this.size)}px` }}      >        {this.children ?? <span class="block size-full rounded-full bg-foreground mix-blend-difference" />}      </div>    );  }}

Install the following dependencies:

npm install @praxisjs/css
pnpm add @praxisjs/css
yarn add @praxisjs/css
bun add @praxisjs/css

Copy and paste the following code into your project.

smooth-cursor.tsx
import { StatefulComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component, Prop, Ref, type Ref as RefType } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);class SmoothCursorStyles extends Stylesheet {  $root = this.css({ pointerEvents: "none", position: "fixed", top: 0, left: 0, zIndex: 100 });  $dot = this.css({ display: "block", height: "100%", width: "100%", borderRadius: "9999px", backgroundColor: t.foreground, mixBlendMode: "difference" });}export interface SmoothCursorProps {  size?: number;  ease?: number;  class?: string;  children?: Children;}/** Writes `transform` directly rather than through `@State`, since the rAF lerp loop needs to keep ticking every frame regardless of pointer movement. */@Component()export class SmoothCursor extends StatefulComponent {  @Styled(SmoothCursorStyles) $s!: SmoothCursorStyles;  @Prop() size = 24;  @Prop() ease = 0.15;  @Prop() class?: string;  @Prop() children?: Children;  @Ref<HTMLDivElement>()  elRef!: RefType<HTMLDivElement>;  private _target = { x: 0, y: 0 };  private readonly _current = { x: 0, y: 0 };  private _frame = 0;  private readonly _handlePointerMove = (event: PointerEvent) => {    this._target = { x: event.clientX, y: event.clientY };  };  private readonly _tick = () => {    this._current.x += (this._target.x - this._current.x) * this.ease;    this._current.y += (this._target.y - this._current.y) * this.ease;    const el = this.elRef.current;    if (el) el.style.transform = `translate(${String(this._current.x)}px, ${String(this._current.y)}px) translate(-50%, -50%)`;    this._frame = requestAnimationFrame(this._tick);  };  onMount(): void {    window.addEventListener("pointermove", this._handlePointerMove);    this._frame = requestAnimationFrame(this._tick);  }  onUnmount(): void {    window.removeEventListener("pointermove", this._handlePointerMove);    cancelAnimationFrame(this._frame);  }  render() {    return (      <div        ref={this.elRef}        data-slot="smooth-cursor"        class={cx(this.$s.$root, this.class)}        style={{ width: `${String(this.size)}px`, height: `${String(this.size)}px` }}      >        {this.children ?? <span class={this.$s.$dot} />}      </div>    );  }}

Examples

About

Purely presentational — no Morphos equivalent. Distinct from Pointer (bounded to a single container, tracks 1:1): mount this once near the app root. Runs its own requestAnimationFrame loop and writes transform directly rather than through @State, since the lerp needs to keep ticking every frame regardless of whether the pointer is moving. Pair with a global cursor: none where you want the native cursor hidden.

Usage

import { SmoothCursor } from "@/components/ui/smooth-cursor";

// once, near the app root
<SmoothCursor size={24} ease={0.15} />

Props

PropTypeDefault
sizenumber (px)24
easenumber (0-1 lerp factor per frame)0.15
classstring

On this page