Rolling text button
An example of rolling a duplicate button label into place on hover with Motion for React.
Source code
"use client"
import { motion, useReducedMotion } from "motion/react"
import { useRef, useState } from "react"
import { FreshChevronRightIcon } from "@motion/design/fresh"
const outgoingVariants = {
rest: { transform: "translateY(0%)" },
active: { transform: "translateY(100%)" },
}
const incomingVariants = {
rest: { transform: "translateY(-100%)" },
active: { transform: "translateY(0%)" },
}
const transition = {
duration: 0.3,
ease: [0.338, 0.015, 0.395, 0.959] as const,
}
export default function RollingTextButton() {
const reduceMotion = useReducedMotion()
const [active, setActive] = useState(false)
const activeRef = useRef(false)
const animating = useRef(false)
const pendingRequest = useRef<boolean | null>(null)
const hovered = useRef(false)
const focused = useRef(false)
const updateActive = (next: boolean) => {
activeRef.current = next
setActive(next)
}
const requestActive = (next: boolean) => {
if (reduceMotion) return
if (next === activeRef.current) {
pendingRequest.current = null
return
}
if (animating.current) {
pendingRequest.current = next
return
}
animating.current = true
updateActive(next)
}
const completeAnimation = () => {
if (!animating.current) return
animating.current = false
if (
pendingRequest.current !== null &&
pendingRequest.current !== activeRef.current
) {
const next = pendingRequest.current
pendingRequest.current = null
animating.current = true
updateActive(next)
} else {
pendingRequest.current = null
}
}
return (
<main className="rolling-button-stage fresh-system">
<motion.button
type="button"
className="fresh-primary-action"
aria-label="Start free"
onHoverStart={() => {
hovered.current = true
requestActive(true)
}}
onHoverEnd={() => {
hovered.current = false
requestActive(focused.current)
}}
onFocus={() => {
focused.current = true
requestActive(true)
}}
onBlur={() => {
focused.current = false
requestActive(hovered.current)
}}
>
<span className="fresh-primary-action-content" aria-hidden="true">
<span className="label-window">
<motion.span
className="label-copy"
variants={outgoingVariants}
initial="rest"
animate={active ? "active" : "rest"}
onAnimationComplete={completeAnimation}
transition={transition}
>
Start free
</motion.span>
<motion.span
className="label-copy label-copy--incoming"
variants={incomingVariants}
initial="rest"
animate={active ? "active" : "rest"}
transition={transition}
>
Start free
</motion.span>
</span>
<span>
<FreshChevronRightIcon />
</span>
</span>
</motion.button>
<Stylesheet />
</main>
)
}
function Stylesheet() {
return (
<style>{`
html, body {
margin: 0;
min-height: 100%;
}
.rolling-button-stage,
.rolling-button-stage * {
box-sizing: border-box;
}
.rolling-button-stage {
--fresh-accent: var(--lab-blueberry);
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 100vh;
}
.label-window {
position: relative;
display: block;
width: max-content;
overflow: hidden;
}
.label-copy {
display: block;
white-space: nowrap;
}
.label-copy--incoming {
position: absolute;
inset: 0;
}
`}</style>
)
}







