Buttons with Sliding Label Swap on Hover

Four buttons that each carry two messages. At rest a button shows its action; on hover, keyboard focus or tap, that face slides out and a second face slides in behind it with a different colour and a short follow-up such as a price, a file size or a count. Each of the four uses a different direction and shape so you can pick the one you need. The motion is plain CSS transforms inside a box with hidden overflow. The buttons sit in a two by two grid on a bold yellow block and stack into one column on phones.

Code

// Adapted from Animata (https://animata.design/docs/button/swipe-button)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { ArrowDownToLine, ArrowRight, ShoppingBag, Timer } from "lucide-react";

// `first` is the resting face, `second` slides in on hover, focus or tap. `from` picks the direction.
const buttons = [
  {
    first: "Get early access",
    second: "Join 2,480 on the list",
    icon: ArrowRight,
    from: "below",
    shape: "rounded-xl",
    firstClass: "bg-orange-500 text-black",
    secondClass: "bg-black text-white",
  },
  {
    first: "Add to bag",
    second: "$48, ships Friday",
    icon: ShoppingBag,
    from: "above",
    shape: "rounded-full",
    firstClass: "bg-black text-white",
    secondClass: "bg-white text-black",
  },
  {
    first: "Download the guide",
    second: "PDF, 2.4 MB",
    icon: ArrowDownToLine,
    from: "left",
    shape: "rounded-none",
    firstClass: "bg-white text-black",
    secondClass: "bg-lime-300 text-black",
  },
  {
    first: "Start free trial",
    second: "14 days, no card",
    icon: Timer,
    from: "right",
    shape: "rounded-2xl",
    firstClass: "bg-violet-600 text-white",
    secondClass: "bg-violet-950 text-white",
  },
] as const;

export default function ButtonsWithSlidingLabelSwap() {
  // Until someone interacts, each button shows its second face in turn.
  const [demo, setDemo] = useState<number | null>(null);
  const [auto, setAuto] = useState(true);
  const [tapped, setTapped] = useState<number | null>(null);
  const tapTimer = useRef<ReturnType<typeof setTimeout>>();

  useEffect(() => {
    if (!auto || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    let step = -1;
    const tick = () => {
      step += 1;
      setDemo(step % buttons.length);
    };
    const first = setTimeout(tick, 400);
    const timer = setInterval(tick, 2000);
    return () => {
      clearTimeout(first);
      clearInterval(timer);
    };
  }, [auto]);

  useEffect(() => () => clearTimeout(tapTimer.current), []);

  const stop = () => {
    setAuto(false);
    setDemo(null);
  };

  return (
    <section className="bg-yellow-300 px-4 py-16 sm:px-6" onPointerEnter={stop} onFocus={stop}>
      <style>{`
        .label-swap-button span { transition: transform 380ms cubic-bezier(0.65, 0, 0.35, 1); }
        .label-swap-button .label-swap-second { position: absolute; inset: 0; }
        .label-swap-button[data-from="below"] .label-swap-second { transform: translateY(100%); }
        .label-swap-button[data-from="above"] .label-swap-second { transform: translateY(-100%); }
        .label-swap-button[data-from="left"] .label-swap-second { transform: translateX(-100%); }
        .label-swap-button[data-from="right"] .label-swap-second { transform: translateX(100%); }
        .label-swap-button:hover .label-swap-second,
        .label-swap-button:focus-visible .label-swap-second,
        .label-swap-button[data-on] .label-swap-second { transform: translate(0, 0); }
        .label-swap-button[data-from="below"]:hover .label-swap-first, .label-swap-button[data-from="below"]:focus-visible .label-swap-first, .label-swap-button[data-from="below"][data-on] .label-swap-first { transform: translateY(-100%); }
        .label-swap-button[data-from="above"]:hover .label-swap-first, .label-swap-button[data-from="above"]:focus-visible .label-swap-first, .label-swap-button[data-from="above"][data-on] .label-swap-first { transform: translateY(100%); }
        .label-swap-button[data-from="left"]:hover .label-swap-first, .label-swap-button[data-from="left"]:focus-visible .label-swap-first, .label-swap-button[data-from="left"][data-on] .label-swap-first { transform: translateX(100%); }
        .label-swap-button[data-from="right"]:hover .label-swap-first, .label-swap-button[data-from="right"]:focus-visible .label-swap-first, .label-swap-button[data-from="right"][data-on] .label-swap-first { transform: translateX(-100%); }
        @media (prefers-reduced-motion: reduce) {
          .label-swap-button span { transition: none; }
        }
      `}</style>
      <div className="mx-auto max-w-3xl">
        <p className="text-sm font-bold uppercase tracking-widest text-black">Button set</p>
        <h2 className="mt-2 max-w-xl text-4xl font-black leading-none tracking-tight text-black sm:text-6xl">Say the next thing before they ask.</h2>
        <p className="mt-4 max-w-lg text-base font-medium text-black">
          Each button answers the question that follows its label: how many, how much, how big, how long. Hover, tab or tap to see it.
        </p>
        <div className="mt-10 grid gap-4 sm:grid-cols-2">
          {buttons.map((button, i) => {
            const on = demo === i || tapped === i;
            return (
              <button
                key={button.first}
                type="button"
                data-from={button.from}
                data-on={on ? "" : undefined}
                aria-label={`${button.first}. ${button.second}`}
                onClick={() => {
                  // On touch screens there is no hover, so a tap shows the second face for a moment.
                  setTapped(i);
                  clearTimeout(tapTimer.current);
                  tapTimer.current = setTimeout(() => setTapped(null), 1600);
                }}
                className={`label-swap-button relative h-16 w-full overflow-hidden border-2 border-black text-lg font-bold shadow-[4px_4px_0_0_#000] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-black/40 sm:text-xl ${button.shape}`}
              >
                <span aria-hidden="true" className={`label-swap-first flex h-full w-full items-center justify-center gap-2.5 px-5 ${button.firstClass}`}>
                  {button.first}
                  <button.icon className="h-5 w-5" />
                </span>
                <span aria-hidden="true" className={`label-swap-second flex items-center justify-center px-5 ${button.secondClass}`}>
                  {button.second}
                </span>
              </button>
            );
          })}
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/buttons-with-sliding-label-swap-on-hover.json

When to use it

How to customize it

Accessibility

Questions

How does the label swap work?

The button hides overflow and holds two spans. The first fills the button; the second is positioned on top of it but pushed fully outside by a transform. On hover both get new transforms, so one slides out as the other slides in.

Will the button change width when the text swaps?

No. The first face sets the size and the second is absolutely positioned inside it. If the second message is longer than the first, shorten it or give the button a fixed or full width as here.

Why do the buttons animate by themselves in the preview?

A small demo shows each second face in turn so the effect is visible without a mouse. It stops for good the first time a pointer or keyboard focus enters the section. Remove the `demo` state and its effect if you do not want it.

Can I use it on a link?

Yes. Change the `button` to an `a` with an `href` and keep the classes, the `data-from` attribute and the two spans. The CSS does not depend on the element type.

Should important information go on the second face?

Treat it as a bonus. Anything a visitor must know before clicking, such as a price they will be charged, should also appear near the button in normal text.

Adapted from Swipe Button in Animata, used under its open-source licence. Licences and credits

Related: Button with Expanding Dot Fill and Arrow, Shimmer Button with Light Sweep in Three Sizes, Menu Links with Rolling Letter Swap on Hover.

More animated buttons or all free UI components. Start for free to have a full site built around it.

More in Animated Buttons

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap