# Tilt Button (`tilt-button`)

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

- **Docs:** https://www.mellowui.com/components/tilt-button
- **Markdown:** https://www.mellowui.com/components/tilt-button.md
- **Registry:** https://www.mellowui.com/r/tilt-button.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/tilt-button
- **Categories:** button, interactive, 3d, animation
- **Dependencies:** motion

## AI prompt

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

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/tilt-button.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `children` | `React.ReactNode` | — | Button label content. |
| `variant` | `"default" \| "accent"` | `"default"` | Color scheme — default is neutral glass, accent is blue. |
| `maxTilt` | `number` | `12` | Maximum tilt angle in degrees. |
| `perspective` | `number` | `700` | CSS perspective distance in pixels. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"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;

```

## Demo

```tsx
"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-6 p-8">
      <div className="flex flex-wrap items-center justify-center 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>
  );
}

```
