Sable UI®

DocsCatalogGithub
Docs 1.0.0

tools

  • Browse Catalog

Documentation

  • How it works
  • How to contribute

Components (21)

  • Orbit Gallery
  • Sphere Gallery
  • Spiral Gallery
  • Edge Bounce
  • Fluid Distortion
  • Image Trail
  • Lens Media
  • Liquid Media
  • Magnetic Dot Grid
  • Pixel Media
  • Pixel Trail
  • Curve Media
  • Infinite Gallery
  • Infinite Parallax
  • Infinite Zoom
  • Pixel Scroll
  • Scattered Scroll
  • Pixelated Text
  • Text Bounce
  • Text Fluid
  • Text Scramble

Foundation Blocks (07)

  • Smooth Scroll
  • Text Split
  • WebGL Image
  • WebGL Provider
  • WebGL Scene
  • WebGL Text
  • WebGL Video
Sable UI 1.0.0 ©2026
Star on githubllms.txt
  1. Docs
  2. /
  3. Components
  4. /
  5. Edge Bounce

Edge Bounce

A wrapper that bounces away from the cursor on hover and springs back into place.

https://sable-ui.com/edge-bounce
pause
0.00
out-duration
0.35
in-duration
1.00
bounce
0.30
distance
35
rotation
25
pause
0.00
out-duration
0.35
in-duration
1.00
bounce
0.30
distance
35
rotation
25
Prompt
Add Sable's Edge Bounce to my app.
Run: npx shadcn@latest add @sable/edge-bounce
That writes the sable-ui skill under .agents/skills and .claude/skills. Follow it.

Props: pause={0} outDuration={0.35} inDuration={1} bounce={0.3} distance={35} rotation={25}

Just into your favorite coding agent and let Sable do its magic. Live props are included.


This command will install all the dependencies this component uses.

npx shadcn@latest add /edge-bounce

Install the dependencies first, then feel free to copy the files into your project as you see fit.

npm install motion
edge-bounce.tsx
"use client"

import { useAnimate, useMotionValue, useVelocity } from "motion/react"
import type React from "react"
import { type ComponentRef, type ReactNode, useEffect, useRef } from "react"

const MAX_BOUNCE_SPEED = 1500

export type EdgeBounceProps = {
    children: ReactNode
    className?: string
    pause?: number
    outDuration?: number
    inDuration?: number
    bounce?: number
    distance?: number
    rotation?: number
}

export function EdgeBounce({
    children,
    className,
    pause = 0,
    outDuration = 0.35,
    inDuration = 1,
    bounce = 0.3,
    distance = 35,
    rotation = 25,
}: EdgeBounceProps) {
    const [scope, animate] = useAnimate<ComponentRef<"div">>()
    const isBouncing = useRef(false)
    const touchInside = useRef(false)
    const pointerX = useMotionValue(0)
    const pointerY = useMotionValue(0)
    const velocityX = useVelocity(pointerX)
    const velocityY = useVelocity(pointerY)

    const bounceFrom = async (fromX: number, fromY: number) => {
        const element = scope.current
        if (!element || isBouncing.current) return

        const velX = velocityX.get()
        const velY = velocityY.get()
        const speed = Math.hypot(velX, velY)
        const speedRatio = Math.min(speed / MAX_BOUNCE_SPEED, 1)

        const rect = element.getBoundingClientRect()
        const deltaX = rect.left + rect.width / 2 - fromX
        const deltaY = rect.top + rect.height / 2 - fromY
        const distanceFromCenter = Math.hypot(deltaX, deltaY)

        if (distanceFromCenter === 0 || speed === 0) return

        const tilt = (deltaY * velX - deltaX * velY) / (distanceFromCenter * speed)

        isBouncing.current = true
        await animate(
            element,
            {
                x: (deltaX / distanceFromCenter) * distance * speedRatio,
                y: (deltaY / distanceFromCenter) * distance * speedRatio,
                rotate: tilt * rotation * speedRatio,
            },
            { duration: outDuration, ease: "circOut" },
        )
        isBouncing.current = false

        animate(
            element,
            { x: 0, y: 0, rotate: 0 },
            { type: "spring", duration: inDuration, bounce, delay: pause },
        )
    }

    useEffect(() => {
        const isOver = (event: PointerEvent) => {
            const rect = scope.current?.getBoundingClientRect()
            if (!rect) return false

            return (
                event.clientX >= rect.left &&
                event.clientX <= rect.right &&
                event.clientY >= rect.top &&
                event.clientY <= rect.bottom
            )
        }

        const handlePointerDown = (event: PointerEvent) => {
            if (event.pointerType === "touch") touchInside.current = isOver(event)
        }

        const handlePointerMove = (event: PointerEvent) => {
            pointerX.set(event.clientX)
            pointerY.set(event.clientY)

            if (event.pointerType !== "touch") return

            const inside = isOver(event)
            if (inside && !touchInside.current) bounceFrom(event.clientX, event.clientY)
            touchInside.current = inside
        }

        window.addEventListener("pointerdown", handlePointerDown)
        window.addEventListener("pointermove", handlePointerMove)

        return () => {
            window.removeEventListener("pointerdown", handlePointerDown)
            window.removeEventListener("pointermove", handlePointerMove)
        }
    })

    const handlePointerEnter = (event: React.PointerEvent<HTMLDivElement>) => {
        if (event.pointerType !== "touch") bounceFrom(event.clientX, event.clientY)
    }

    return (
        <div
            ref={scope}
            className={`table will-change-transform ${className ?? ""}`}
            onPointerEnter={handlePointerEnter}
        >
            {children}
        </div>
    )
}

Wrap the content that should bounce away from the cursor.

<EdgeBounce>
    <img src="https://picsum.photos/seed/sable-1/1200/800" alt="Photo" />
</EdgeBounce>

NameTypeDefaultDescription
childrenReactNode—Content to render inside the wrapper. Required.
classNamestring—Class names applied to the wrapper.
pausenumber0Delay in seconds before springing back.
outDurationnumber0.35Duration in seconds for the wrapper moving away.
inDurationnumber1Duration in seconds for the spring-back animation.
bouncenumber0.3Bounciness of the spring-back. 0 = no overshoot, 1 = maximum oscillation.
distancenumber35Maximum pixels the wrapper moves from its origin, reached at full pointer speed.
rotationnumber25Maximum rotation in degrees, following the pointer's travel direction. 0 disables it.

Motion
React animation library.

Star on githubllms.txt