Skip to main content
Back to discovery

Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.

Tilt Button

A 3D button that tilts in perspective toward the cursor with a spring snap on press.

MSource LandMellow UI

Why it stands out

A 3D button that tilts in perspective toward the cursor with a spring snap on press.

Prompt

You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11 (install: `pnpm add motion`)

If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.

Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.

Copy-paste these files:

File location: components/tilt-button-demo.tsx

File content: "use client";

import React from "react";
import { TiltButton } from "../mellow/tilt-button";

export default function TiltButtonDemo() {
  return (
    <div className="flex flex-col items-center gap-4 p-4 sm:gap-6 sm:p-8">
      <div className="flex flex-wrap items-center justify-center gap-3 sm:gap-4">
        <TiltButton>Get Started</TiltButton>
        <TiltButton>Browse Docs</TiltButton>
        <TiltButton variant="accent">Deploy Now</TiltButton>
      </div>
      <div className="flex flex-wrap items-center justify-center gap-4">
        <TiltButton maxTilt={18}>High Tilt</TiltButton>
        <TiltButton variant="accent" maxTilt={6}>Subtle Tilt</TiltButton>
        <TiltButton disabled>Disabled</TiltButton>
      </div>
      <p className="text-[rgba(var(--ink-rgb),0.35)] text-[0.8125rem]">
        Move cursor within the button to tilt · click to snap flat
      </p>
    </div>
  );
}



File location: components/mellow/tilt-button.tsx

File content: "use client";

import React, {
  useRef,
  useState,
  useCallback,
  type ButtonHTMLAttributes,
} from "react";
import { motion, useMotionValue, useSpring } from "motion/react";

export interface TiltButtonProps
  extends Omit<
    ButtonHTMLAttributes<HTMLButtonElement>,
    "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
  > {
  children: React.ReactNode;
  variant?: "default" | "accent";
  maxTilt?: number;
  perspective?: number;
}

export function TiltButton({
  children,
  variant = "default",
  maxTilt = 12,
  perspective = 700,
  className,
  style,
  disabled,
  onMouseMove,
  onMouseLeave,
  onMouseDown,
  onMouseUp,
  ...props
}: TiltButtonProps) {
  const ref = useRef<HTMLButtonElement>(null);
  const [pressed, setPressed] = useState(false);

  const rotX = useMotionValue(0);
  const rotY = useMotionValue(0);
  const sc = useMotionValue(1);

  const springCfg = { stiffness: 350, damping: 28, mass: 0.6 };
  const springRotX = useSpring(rotX, springCfg);
  const springRotY = useSpring(rotY, springCfg);
  const springScale = useSpring(sc, { stiffness: 400, damping: 30 });

  const handleMouseMove = useCallback(
    (e: React.MouseEvent<HTMLButtonElement>) => {
      if (!ref.current || pressed) return;
      const rect = ref.current.getBoundingClientRect();
      const nx = (e.clientX - rect.left - rect.width / 2) / (rect.width / 2);
      const ny = (e.clientY - rect.top - rect.height / 2) / (rect.height / 2);
      rotY.set(nx * maxTilt);
      rotX.set(-ny * maxTilt);
      onMouseMove?.(e);
    },
    [pressed, maxTilt, rotX, rotY, onMouseMove]
  );

  const handleMouseLeave = useCallback(
    (e: React.MouseEvent<HTMLButtonElement>) => {
      rotX.set(0);
      rotY.set(0);
      sc.set(1);
      setPressed(false);
      onMouseLeave?.(e);
    },
    [rotX, rotY, sc, onMouseLeave]
  );

  const handleMouseDown = useCallback(
    (e: React.MouseEvent<HTMLButtonElement>) => {
      rotX.set(0);
      rotY.set(0);
      sc.set(0.95);
      setPressed(true);
      onMouseDown?.(e);
    },
    [rotX, rotY, sc, onMouseDown]
  );

  const handleMouseUp = useCallback(
    (e: React.MouseEvent<HTMLButtonElement>) => {
      sc.set(1);
      setPressed(false);
      onMouseUp?.(e);
    },
    [sc, onMouseUp]
  );

  const isAccent = variant === "accent";

  return (
    <div className="inline-flex" style={{ perspective: `${perspective}px` }}>
      <motion.button
        ref={ref}
        disabled={disabled}
        className={[
          "relative inline-flex items-center justify-center gap-2 px-5 py-[0.6rem] rounded-[8px]",
          "[font-family:var(--font-sans)] text-sm font-medium tracking-[-0.01em]",
          "select-none [transform-style:preserve-3d] transition-shadow duration-200",
          disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
          isAccent
            ? "border border-[oklch(0.62_0.25_250/0.6)] bg-[oklch(0.50_0.22_250)] text-white"
            : "border border-[rgba(var(--ink-rgb),0.12)] bg-[rgba(var(--ink-rgb),0.07)] text-[var(--ink)]",
          className,
        ].filter(Boolean).join(" ")}
        style={{
          rotateX: springRotX,
          rotateY: springRotY,
          scale: springScale,
          ...(style as object),
        }}
        onMouseMove={handleMouseMove}
        onMouseLeave={handleMouseLeave}
        onMouseDown={handleMouseDown}
        onMouseUp={handleMouseUp}
        {...props}
      >
        <span
          aria-hidden="true"
          className="absolute left-[2px] right-[2px] bottom-0 h-2 rounded-b-[6px] bg-[rgba(var(--ink-rgb),0.04)]"
          style={{
            transform: "translateY(100%) rotateX(-90deg)",
            transformOrigin: "top center",
          }}
        />
        <span
          aria-hidden="true"
          className="absolute inset-0 rounded-[inherit] pointer-events-none bg-gradient-to-b from-white/10 to-transparent"
        />
        <span className={["relative isolate", isAccent ? "text-white" : ""].filter(Boolean).join(" ")}>
          {children}
        </span>
      </motion.button>
    </div>
  );
}

export default TiltButton;



Usage notes:
Add a TiltButton from the mellow library. It tracks the cursor and tilts in 3D perspective toward it using spring animation, revealing a bottom edge. On press it snaps flat and springs back. Use variant='default' for neutral glass or variant='accent' for blue. Props: maxTilt (default 12°) and perspective (default 700px).

Discovery vocabulary

Related by governed terms

Continue comparing

More from Mellow UI