Kian Bazza
Home
Craft
ProjectsTools

Cursor-origin background scale

October 2025

I saw this post on my X timeline and was intrigued.

Subtle cursor-origin background scale by @merycodes.

I frequently see design posts like this and think to myself, "this looks pretty cool, I should try to recreate this."

Fast forward a few days, and nothing happens. That post, which once captivated my attention, is now out of mind. Another post will take its place to restart the cycle.

Well today I said: enough is enough. Today is the day.

Prototype

Try hovering the button to see the button's background appear.

When the button is hovered, the background originates from the cursor location.

I also extended the effect to the pointerleave event; when the button is un-hovered, the background "disappears" from the where the cursor left the button.

Breakdown

First, we can create the basic button element with the appropriate styling and content.

<button
  type="button"
  className={cn(
    'font-sans font-[450] text-sm',
    'flex items-center gap-2 h-9 px-3 rounded-xl',
    'bg-sand-3 hover:bg-sand-4 text-sand-11 hover:text-sand-12',
    'active:scale-97 active:bg-sand-4',
    'transition-[color,background-color] duration-150 ease-out',
  )}
>
  <ListFilterPlusIcon className="size-4 stroke-[2.25px]" />
  <span>Add filter</span>
</button>

Next, the main problem - how we do we (1) scale the background and do this (2) from a specific origin?

Initially, I naively applied transform-origin on the background transition, which didn't really work.

It seems like we need to treat the element's background as a separate element entirely and not only apply transform-origin, but also scale this element.

Enter the ::before pseudo-element.

Let's try to replicate the basic button with a slight modification to use a pseudo-element for the background.

Whoops, seems like the pseudo-element is appearing on top of the button's contents.

We can fix this by applying z-index: 1 to the button's children. This can be done quickly with a direct child selector *:z-1... nice!

<button
  type="button"
  className={cn(
    'font-sans font-[450] text-sm',
    'relative flex items-center gap-2 h-9 px-3 rounded-xl',
    '*:z-1',
    'text-sand-11 hover:text-sand-12',
    'active:scale-97 active:bg-sand-4',
    'transition-[color,scale] duration-150 ease-out',
    'before:absolute before:inset-0',
    'before:rounded-xl before:bg-sand-3',
    'before:opacity-0 hover:before:opacity-100',
    'before:transition-[opacity,background-color] before:ease-out before:duration-150',
  )}
>
  <ListFilterPlusIcon className="size-4 stroke-[2.25px]" />
  <span>Add filter</span>
</button>

Now that we have our background rendered via pseudo-element, we can scale it from the cursor's origin of entry/exit.

Before diving in, let's take a moment to review how transform-origin works:

transform-origin accepts one to three values that define the point around which a transform is applied. The first two values are the x and y positions, and optionally a third z value in 3D transforms. These values are offsets relative to the element’s border box.

You can use keywords (left, center, right, top, bottom), lengths (e.g. 20px, 1rem), or percentages. For our purposes, we need to be more specific than just keywords, since the cursor origin is dynamic.

Now we can get started. First, we need to be able to detect the cursor's origin:

function setCursorOrigin(el: HTMLElement, e: PointerEvent) {
  const { clientX, clientY } = e
}

Now, let's compute the cursor's origin relative to the element's bounding box. We can store these as CSS variables --x and --y on the element.

const { clientX, clientY } = e

const { top, left } = el.getBoundingClientRect()

const x = clientX - left
const y = clientY - top

el.style.setProperty('--x', `${x}px`)
el.style.setProperty('--y', `${y}px`)

Now, we can set transform-origin as var(--x) var(--y). We again set this as a CSS variable, --cursor-origin, then set the transform-origin value by referencing this variable.

This completes our setCursorOrigin helper function, which I've chosen to define outside our component:

function setCursorOrigin(el: HTMLElement, e: PointerEvent) {
  const { clientX, clientY } = e

  const { top, left } = el.getBoundingClientRect()

  const x = clientX - left
  const y = clientY - top

  el.style.setProperty('--x', `${x}px`)
  el.style.setProperty('--y', `${y}px`)

  el.style.setProperty('--cursor-origin', `var(--x) var(--y)`)
}

Next, we wire up our setCursorOrigin function to the pointerenter and pointerleave events. For this, we can use a callback ref.

We also set the transform-origin value by referencing our configured CSS variable.

<button
  className={cn(
    // ...,
    'before:origin-(--cursor-origin)', 
    // ...
  )}
  ref={(el) => {
    if (!el) return
    el.addEventListener('pointerenter', (e) => setCursorOrigin(el, e))
    el.addEventListener('pointerleave', (e) => setCursorOrigin(el, e))
  }}
/>

Code

function setCursorOrigin(el: HTMLElement, e: PointerEvent) {
  const { clientX, clientY } = e

  const { top, left } = el.getBoundingClientRect()

  const x = clientX - left
  const y = clientY - top

  el.style.setProperty('--x', `${x}px`)
  el.style.setProperty('--y', `${y}px`)
  el.style.setProperty('--cursor-origin', `var(--x) var(--y)`)
}

export const OriginAwareButton = () => {
  return (
    <button
      type="button"
      className={cn(
        'text-sand-11 font-sans font-[450] text-sm',
        'flex items-center gap-2 h-9 px-3',
        'relative *:z-1',
        'hover:text-sand-12',
        'active:scale-97 active:before:bg-sand-4',
        'before:absolute before:inset-0 before:z-0 before:rounded-xl before:bg-sand-3',
        'before:origin-(--cursor-origin) before:transition-[scale,opacity,color,background-color] before:ease-out before:duration-(--duration)',
        'before:scale-0 before:opacity-50',
        'hover:before:scale-100 hover:before:opacity-100',
      )}
      ref={(el) => {
        if (!el) return

        el.addEventListener('pointerenter', (e) => setCursorOrigin(el, e))
        el.addEventListener('pointerleave', (e) => setCursorOrigin(el, e))
      }}
    >
      <ListFilterPlusIcon className="size-4 stroke-[2.25px]" />
      <span>Add filter</span>
    </button>
  )
}

That's it. Hope you enjoyed.