Skip to main content

Roll Reveal Button

A crisp 3D perspective button. The label and icon each live on two stacked faces that roll in opposite directions on hover, revealing an identical copy of themselves via rotateX and perspective transforms.

ButtonReactTailwind CSSMotion
CSSTailwind

Manual

Create a file and paste the following code into it.

src/components/ui/roll-reveal-button.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
"use client";

import { motion, useReducedMotion, type HTMLMotionProps, type Transition, type Variants } from "motion/react";
import { ArrowRight } from "@phosphor-icons/react";
import { cn } from "@/lib/cn";

const SPRING: Transition = { type: "spring", stiffness: 380, damping: 30, mass: 0.8 };

function rollVariants({
  restRotateX,
  restY,
  hoverRotateX,
  hoverY,
}: {
  restRotateX: number;
  restY: number;
  hoverRotateX: number;
  hoverY: number;
}): Variants {
  return {
    rest: { opacity: restRotateX === 0 && restY === 0 ? 1 : 0, rotateX: restRotateX, y: restY },
    hover: { opacity: hoverRotateX === 0 && hoverY === 0 ? 1 : 0, rotateX: hoverRotateX, y: hoverY },
  };
}

const FACE_BASE =
  "absolute inset-0 grid place-items-center bg-white text-[#171515] shadow-[0_22px_60px_rgba(31,24,21,0.1)] [backface-visibility:hidden] group-focus-visible:ring-2 group-focus-visible:ring-black/70 group-focus-visible:ring-offset-4 group-focus-visible:ring-offset-[#d4f1f4]";
const LABEL_FACE = "rounded-full px-12 text-5xl font-semibold leading-none tracking-normal max-sm:px-9 max-sm:text-4xl";
const ICON_FACE = "rounded-[10px] [&>svg]:h-12 [&>svg]:w-12 max-sm:[&>svg]:h-10 max-sm:[&>svg]:w-10";

const labelTop = rollVariants({ restRotateX: 0, restY: 0, hoverRotateX: -76, hoverY: -58 });
const labelBottom = rollVariants({ restRotateX: 76, restY: 58, hoverRotateX: 0, hoverY: 0 });
const iconTop = rollVariants({ restRotateX: 0, restY: 0, hoverRotateX: 70, hoverY: 56 });
const iconBottom = rollVariants({ restRotateX: -70, restY: -56, hoverRotateX: 0, hoverY: 0 });

function RollRevealButton({
  children = "Button",
  icon = <ArrowRight aria-hidden="true" weight="bold" />,
  className,
  type = "button",
  ...props
}: {
  children?: React.ReactNode;
  icon?: React.ReactNode;
  className?: string;
} & Omit<HTMLMotionProps<"button">, "children" | "className">) {
  const reduceMotion = useReducedMotion();
  const hoverKey = reduceMotion ? undefined : "hover";

  return (
    <motion.button
      type={type}
      initial="rest"
      whileHover={hoverKey}
      whileFocus={hoverKey}
      // Press feedback stays even under reduced-motion: a subtle scale-down is
      // instant tactile feedback, not vestibular motion.
      whileTap={{ scale: 0.985 }}
      className={cn(
        "group inline-flex items-center gap-2 border-0 bg-transparent p-0 outline-none disabled:pointer-events-none disabled:opacity-60",
        "[perspective:760px]",
        className
      )}
      {...props}
    >
      <span className="relative block h-24 w-72 [transform-style:preserve-3d] max-sm:h-20 max-sm:w-56">
        <motion.span variants={labelTop} transition={SPRING} className={cn(FACE_BASE, LABEL_FACE, "[transform-origin:50%_0%]")}>
          {children}
        </motion.span>
        <motion.span
          variants={labelBottom}
          transition={SPRING}
          className={cn(FACE_BASE, LABEL_FACE, "[transform-origin:50%_100%]")}
          aria-hidden="true"
        >
          {children}
        </motion.span>
      </span>
      <span className="relative block h-24 w-24 [transform-style:preserve-3d] max-sm:h-20 max-sm:w-20">
        <motion.span variants={iconTop} transition={SPRING} className={cn(FACE_BASE, ICON_FACE, "[transform-origin:50%_100%]")}>
          {icon}
        </motion.span>
        <motion.span
          variants={iconBottom}
          transition={SPRING}
          className={cn(FACE_BASE, ICON_FACE, "[transform-origin:50%_0%]")}
          aria-hidden="true"
        >
          {icon}
        </motion.span>
      </span>
    </motion.button>
  );
}

export default function RollRevealButtonDemo() {
  return (
    <main className="grid h-dvh w-full place-items-center bg-[#d4f1f4] px-6">
      <RollRevealButton />
    </main>
  );
}

Update the import paths to match your project setup.

Similar components

Button Tilt

Gradient Party Button

Max

Animated Border Button

GitHub Stars Button

Resource details

PublishedJuly 6, 2026
CategoryButton
ReactTailwind CSSMotion