Service List with Text That Swaps to the Price on Hover

A numbered list of five services in large type. Each row is a one-line window: on hover the service name slides up and out, and a second line with the starting price and timescale slides in from below in an accent colour, while the plus icon at the end of the row turns into a filled cross. Clicking or tapping a row pins the swap, so it works without hover. Until someone uses it, the list shows the swap on one row at a time. A heading, a short note and a booking button complete the section. Long lines are cut with an ellipsis on narrow phones instead of wrapping.

Code

// Adapted from Animata (https://animata.design/docs/text/swap-text)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";

const services = [
  { name: "Kitchen refit", detail: "From £14,500, six weeks" },
  { name: "Loft conversion", detail: "From £38,000, ten weeks" },
  { name: "Bathroom rebuild", detail: "From £7,900, three weeks" },
  { name: "Garden room", detail: "From £21,000, five weeks" },
  { name: "Whole-house rewire", detail: "From £6,200, two weeks" },
];

function SwapRow({ index, name, detail, flipped, onToggle }: { index: number; name: string; detail: string; flipped: boolean; onToggle: () => void }) {
  const slide = "block transition-transform duration-700 ease-[cubic-bezier(0.76,0,0.24,1)] motion-reduce:transition-none";
  const up = flipped ? "-translate-y-full" : "group-hover:-translate-y-full";
  return (
    <li className="border-b border-teal-800">
      <button
        type="button"
        aria-pressed={flipped}
        onClick={onToggle}
        className="group flex w-full items-center gap-4 py-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-amber-300 sm:gap-8 sm:py-5"
      >
        <span className="w-7 shrink-0 text-sm tabular-nums text-teal-300">{String(index + 1).padStart(2, "0")}</span>
        {/* The window: one line tall, with the second line parked just below it. */}
        <span className="relative block min-w-0 flex-1 overflow-hidden text-2xl font-semibold leading-tight tracking-tight sm:text-4xl lg:text-5xl lg:leading-tight">
          <span className={`${slide} ${up} truncate text-white`}>{name}</span>
          <span className={`${slide} ${up} absolute inset-x-0 top-full truncate text-amber-300`}>{detail}</span>
        </span>
        <span
          aria-hidden="true"
          className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full border transition duration-500 motion-reduce:transition-none ${
            flipped ? "rotate-45 border-amber-300 bg-amber-300 text-teal-950" : "border-teal-600 text-teal-100 group-hover:rotate-45 group-hover:border-amber-300 group-hover:bg-amber-300 group-hover:text-teal-950"
          }`}
        >
          <svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
            <path d="M10 4v12M4 10h12" />
          </svg>
        </span>
      </button>
    </li>
  );
}

export default function ServiceListWithTextThatSwapsToThePriceOnHover() {
  const [flipped, setFlipped] = useState<number | null>(null);
  const [idle, setIdle] = useState(true);

  // Shows the swap on one row at a time until someone uses the list.
  useEffect(() => {
    if (!idle) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    let row = 0;
    const first = window.setTimeout(() => setFlipped(0), 500);
    const timer = window.setInterval(() => {
      row = (row + 1) % services.length;
      setFlipped(row);
    }, 1900);
    return () => {
      window.clearTimeout(first);
      window.clearInterval(timer);
    };
  }, [idle]);

  const stop = () => {
    if (!idle) return;
    setIdle(false);
    setFlipped(null);
  };

  return (
    <section className="bg-teal-950 px-4 py-14 sm:px-6 sm:py-20">
      <div className="mx-auto max-w-4xl">
        <p className="text-sm font-semibold uppercase tracking-widest text-amber-300">What we build</p>
        <div className="mt-3 flex flex-wrap items-end justify-between gap-4">
          <h2 className="text-3xl font-semibold tracking-tight text-white sm:text-4xl">Five jobs we do every month.</h2>
          <p className="text-sm text-teal-100">Hover or tap a job to see the price.</p>
        </div>

        <ul className="mt-8 border-t border-teal-800" onPointerEnter={stop} onFocus={stop}>
          {services.map((service, index) => (
            <SwapRow
              key={service.name}
              index={index}
              name={service.name}
              detail={service.detail}
              flipped={flipped === index}
              onToggle={() => {
                setIdle(false);
                setFlipped((current) => (current === index ? null : index));
              }}
            />
          ))}
        </ul>

        <div className="mt-8 flex flex-wrap items-center justify-between gap-4">
          <p className="max-w-md text-sm leading-relaxed text-teal-100">
            Prices include materials, a site manager and the final clean. Every quote is fixed before we start.
          </p>
          <a
            href="#"
            className="inline-flex h-12 items-center rounded-full bg-amber-300 px-6 text-sm font-bold text-teal-950 transition hover:bg-amber-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200 focus-visible:ring-offset-2 focus-visible:ring-offset-teal-950"
          >
            Book a site visit
          </a>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/service-list-with-text-that-swaps-to-the-price-on-hover.json

When to use it

How to customize it

Accessibility

Questions

How does the swap work without JavaScript animation?

The row's text area has hidden overflow and is one line tall. The second line is absolutely positioned just below it. Moving both lines up by 100 percent of their own height slides the first out of the window and the second into it. It is one CSS transform with a transition.

Why are there both a hover class and a state class?

`group-hover:-translate-y-full` handles the mouse with no JavaScript. The `flipped` state applies the same class when a row is clicked, tapped or toggled by keyboard, which covers devices with no hover.

What if the second line is longer than the first?

Both lines take the full width of the row and use `truncate`, so a longer second line is not clipped to the first line's width. It only shortens if it is wider than the row itself.

Can each row be a link instead of a button?

Yes. Replace the `<button>` with an `<a>` and remove `aria-pressed` and `onClick`. Hover will still show the swap. Add `group-focus-visible:-translate-y-full` so keyboard users see it too.

Can I swap more than two lines?

The pattern suits two. For three or more states, a rotating text component that cycles a list is a better fit.

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

Related: Menu Links with Rolling Letter Swap on Hover, Menu Words with Sliced Echo Layers That Lift on Hover, Expanding Card Grid on Hover and Focus.

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

More in Text Effects

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