Menu Words with Sliced Echo Layers That Lift on Hover

A full-width menu of four oversized words for a studio site. Above every word sit three thin strips, each showing only the top slice of the same word at a lighter strength, like an echo trailing upward. On hover or keyboard focus the word lifts and turns red, and the strips follow one after another, so the stack fans out and settles. A note and an arrow sit at the right end of each row. The menu steps through its links on its own until a visitor points at one. Type is sized against the screen width, so the words fill a phone without wrapping.

Code

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

const links = [
  { label: "Work", note: "38 projects", href: "#" },
  { label: "Studio", note: "Since 2011", href: "#" },
  { label: "Journal", note: "New this week", href: "#" },
  { label: "Contact", note: "Lisbon and Leeds", href: "#" },
];
// Each echo is a thin strip showing only the top of the word. Strips further up start moving later.
const ECHOES = [
  { delay: 200, opacity: 0.25 },
  { delay: 110, opacity: 0.45 },
  { delay: 50, opacity: 0.7 },
];
const LIFT = "-0.2em";

export default function MenuWordsWithSlicedEchoLayersThatLiftOnHover() {
  const [active, setActive] = useState<number | null>(0);
  const [idle, setIdle] = useState(true);

  // Steps through the links on its own until a pointer or the keyboard takes over.
  useEffect(() => {
    if (!idle) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const timer = window.setInterval(() => setActive((current) => ((current ?? -1) + 1) % links.length), 1500);
    return () => window.clearInterval(timer);
  }, [idle]);

  const take = (index: number) => {
    setIdle(false);
    setActive(index);
  };
  const move = "transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none";

  return (
    <section className="bg-amber-50 px-4 py-14 sm:px-6 sm:py-20">
      <div className="mx-auto max-w-5xl">
        <div className="flex items-center justify-between border-b border-stone-900 pb-4">
          <a href="#" className="rounded text-sm font-bold uppercase tracking-[0.25em] text-stone-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-600 focus-visible:ring-offset-4 focus-visible:ring-offset-amber-50">
            Halden &amp; Roe
          </a>
          <p className="text-sm text-stone-700">Menu</p>
        </div>

        <nav aria-label="Main" className="mt-4">
          <ul onPointerLeave={() => setIdle(true)}>
            {links.map((link, index) => {
              const on = active === index;
              const shift = { transform: on ? `translateY(${LIFT})` : "translateY(0)" };
              return (
                <li key={link.label} className="border-b border-stone-300">
                  <a
                    href={link.href}
                    onPointerEnter={() => take(index)}
                    onFocus={() => take(index)}
                    className="group flex flex-col items-start justify-between gap-1 overflow-hidden sm:flex-row sm:items-end sm:gap-4 pb-2 pt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-600 sm:pb-3 sm:pt-7"
                  >
                    <span className={`block text-[17vw] font-light uppercase leading-none tracking-tight sm:text-7xl lg:text-8xl ${on ? "text-red-600" : "text-stone-900"} transition-colors duration-300`}>
                      {ECHOES.map((echo) => (
                        <span
                          key={echo.delay}
                          aria-hidden="true"
                          className={`block h-[0.15em] overflow-hidden ${move}`}
                          style={{ ...shift, transitionDelay: `${echo.delay}ms`, opacity: echo.opacity }}
                        >
                          <span className="block -translate-y-[0.12em] leading-none">{link.label}</span>
                        </span>
                      ))}
                      <span className={`block ${move}`} style={shift}>
                        {link.label}
                      </span>
                    </span>
                    <span className={`shrink-0 text-right text-sm text-stone-700 transition-opacity duration-300 sm:mb-4 sm:text-base ${on ? "opacity-100" : "opacity-60"}`}>
                      {link.note}
                      <svg viewBox="0 0 20 20" className={`ml-2 inline h-4 w-4 transition-transform duration-300 motion-reduce:transition-none ${on ? "translate-x-0" : "-translate-x-2"}`} fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
                        <path d="M4 10h12M11 5l5 5-5 5" />
                      </svg>
                    </span>
                  </a>
                </li>
              );
            })}
          </ul>
        </nav>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/menu-words-with-sliced-echo-layers-that-lift-on-hover.json

When to use it

How to customize it

Accessibility

Questions

How are the echo strips made?

Each strip is a block with a small fixed height and hidden overflow, containing a full copy of the word. Only the top few pixels of the letters fit, so you see a slice. Stacking three of them above the real word gives the trailing look.

What creates the fanning motion?

Every layer gets the same upward move, but each strip has a longer `transition-delay` than the one below it. The word moves first and the strips catch up in order.

Why set the size in em units inside the component?

The strip height and the lift are both in em, so they stay in proportion to the letters at every breakpoint. You can change the font size classes without touching the effect.

Can I use it on a dark background?

Yes. Swap `bg-amber-50` for a dark colour, set the words to a light colour and pick a bright active colour. The strips use opacity, so they adapt to whatever colour the word has.

How do I stop it cycling by itself?

Delete the `useEffect` with the interval and start `active` as `null`. The links then rest flat until hovered or focused.

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

Related: Menu Links with Rolling Letter Swap on Hover, Link Underline That Fills Into a Highlight on Hover, Headline That Splits Open Under the Pointer.

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