Integration Tiles That Lift in Turn with Travelling Light

A feature card for saying "works with everything you use". Three square icon tiles sit in a row with 3D perspective. One at a time, each lifts off the surface, tilts back slightly and gains a coloured shadow, then settles as the next one rises. Behind them a thin stepped wire crosses the card and a small light travels along it. The caption under the tiles changes to describe whichever tile is raised, and hovering or focusing a tile holds it up. The demo places the card beside a short pitch with three bullet points. On phones the pitch stacks above the card and the tiles shrink to 80px.

Code

// Adapted from Eldora UI (https://github.com/karthikmudunuri/eldoraui/blob/main/apps/www/registry/eldoraui/animated-frameworks.tsx)
// Copyright (c) Mudunuri Bhaskara Karthikeya Varma. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import { useReducedMotion } from "framer-motion";
import { Check, Globe, Server, Smartphone } from "lucide-react";

const tiles = [
  { icon: Smartphone, label: "Phone apps", detail: "Kits for both mobile platforms" },
  { icon: Globe, label: "Websites", detail: "One script tag or a package" },
  { icon: Server, label: "Servers", detail: "Six backend languages" },
];

const points = ["The same four calls on every platform", "Typed responses and clear error codes", "Sandbox keys that never touch real money"];

// The stepped wire behind the tiles, in a 100 x 100 drawing.
const WIRE = "M 2 0 v 6 q 0 6 6 6 h 36 q 6 0 6 6 v 64 q 0 6 6 6 h 36 q 6 0 6 6 v 6";
const STEP_MS = 1700;

export default function IntegrationTilesThatLiftInTurnWithTravellingLight() {
  const reduce = useReducedMotion();
  const [active, setActive] = useState(0);
  const [held, setHeld] = useState<number | null>(null);

  // One tile is raised at a time; the next rises as the last one settles.
  useEffect(() => {
    if (reduce) return;
    const id = setInterval(() => setActive((a) => (a + 1) % tiles.length), STEP_MS);
    return () => clearInterval(id);
  }, [reduce]);

  const raised = held ?? (reduce ? -1 : active);
  const shown = held ?? active;

  return (
    <section className="bg-slate-100 px-4 py-16 sm:px-6 sm:py-24">
      <style>{`
        @keyframes integration-tiles-that-lift-in-turn-with-travelling-light-run {
          0% { offset-distance: 0%; }
          70%, 100% { offset-distance: 100%; }
        }
        .integration-tiles-that-lift-in-turn-with-travelling-light-spark {
          offset-anchor: 0 0;
          offset-rotate: 0deg;
          animation: integration-tiles-that-lift-in-turn-with-travelling-light-run 4.2s linear infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .integration-tiles-that-lift-in-turn-with-travelling-light-spark { animation: none; offset-distance: 50%; }
        }
      `}</style>

      <div className="mx-auto grid max-w-5xl items-center gap-10 lg:grid-cols-2 lg:gap-16">
        <div>
          <p className="text-sm font-semibold uppercase tracking-widest text-blue-700">Works where you build</p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl">One payments API for your app, your site and your server</h2>
          <p className="mt-4 text-lg leading-relaxed text-slate-600">
            Tollgate ships the same small set of calls for every place you take money, so the checkout you build once behaves the same everywhere.
          </p>
          <ul className="mt-7 space-y-3">
            {points.map((p) => (
              <li key={p} className="flex items-start gap-3 text-slate-700">
                <span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-600 text-white">
                  <Check className="h-3 w-3" strokeWidth={3} aria-hidden="true" />
                </span>
                {p}
              </li>
            ))}
          </ul>
          <a href="#" className="mt-8 inline-flex h-12 items-center rounded-lg bg-slate-900 px-6 text-sm font-semibold text-white transition-colors hover:bg-blue-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100">
            Read the quickstart
          </a>
        </div>

        <div className="rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5">
          <div className="relative flex h-64 items-center justify-center overflow-hidden rounded-t-2xl bg-[radial-gradient(circle_at_50%_40%,rgb(239,246,255),white_70%)] sm:h-72">
            <svg viewBox="0 0 100 100" preserveAspectRatio="none" fill="none" className="absolute left-1/2 top-0 h-full w-[17rem] -translate-x-1/2 sm:w-[21rem]" aria-hidden="true">
              <defs>
                <mask id="integration-tiles-wire-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
                  <path d={WIRE} stroke="white" strokeWidth="0.9" />
                </mask>
                <radialGradient id="integration-tiles-spark">
                  <stop offset="0%" stopColor="#2563eb" />
                  <stop offset="100%" stopColor="#2563eb" stopOpacity="0" />
                </radialGradient>
              </defs>
              <path d={WIRE} stroke="rgb(203,213,225)" strokeWidth="0.4" />
              <g mask="url(#integration-tiles-wire-mask)">
                <circle r="16" fill="url(#integration-tiles-spark)" className="integration-tiles-that-lift-in-turn-with-travelling-light-spark" style={{ offsetPath: `path("${WIRE}")` } as CSSProperties} />
              </g>
            </svg>

            <ul className="relative flex items-center justify-center gap-3 [perspective:900px] sm:gap-5">
              {tiles.map((t, i) => {
                const up = raised === i;
                return (
                  <li key={t.label}>
                    <button
                      type="button"
                      aria-pressed={shown === i}
                      aria-label={t.label}
                      onPointerEnter={() => setHeld(i)}
                      onPointerLeave={() => setHeld(null)}
                      onFocus={() => setHeld(i)}
                      onBlur={() => setHeld(null)}
                      style={{ transform: up ? "translateY(-10px) rotateX(14deg) translateZ(24px)" : "none" }}
                      className={`flex h-20 w-20 items-center justify-center rounded-xl border bg-gradient-to-b from-white to-slate-100 transition-[transform,box-shadow,border-color] duration-1000 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 motion-reduce:transition-none sm:h-28 sm:w-28 ${
                        up ? "border-blue-300 shadow-[0_26px_40px_-14px_rgba(37,99,235,0.45)]" : "border-slate-300 shadow-[0_8px_24px_-8px_rgba(15,23,42,0.18)]"
                      }`}
                    >
                      <t.icon className={`h-7 w-7 transition-colors duration-700 sm:h-9 sm:w-9 ${shown === i ? "text-blue-600" : "text-slate-600"}`} aria-hidden="true" />
                    </button>
                  </li>
                );
              })}
            </ul>
          </div>

          <div className="border-t border-slate-200 p-5 sm:p-6" aria-live="off">
            <p className="text-base font-semibold text-slate-900">{tiles[shown].label}</p>
            <p className="mt-1 text-sm text-slate-600">{tiles[shown].detail}</p>
            <div className="mt-4 flex gap-1.5" aria-hidden="true">
              {tiles.map((t, i) => (
                <span key={t.label} className={`h-1 flex-1 rounded-full transition-colors duration-500 ${shown === i ? "bg-blue-600" : "bg-slate-200"}`} />
              ))}
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/integration-tiles-that-lift-in-turn-with-travelling-light.json

When to use it

How to customize it

Accessibility

Questions

How is the lift animated?

Each tile has a CSS transition on `transform`. A timer changes which tile is marked as raised, and the browser animates between the flat and lifted transforms. Because the transition is longer than half the step, one tile is still settling as the next rises, which gives the wave feel.

Why does the parent need perspective?

`rotateX` and `translateZ` only look three-dimensional when an ancestor sets `perspective`. Without it the tilt would just squash the tile vertically.

How does the light follow the wire?

The light is an SVG circle with `offset-path` set to the same path as the wire, and a keyframe animation moves it from start to end. A mask in the shape of the wire hides the glow everywhere except on the line.

Can I use brand logos on the tiles?

Yes. Replace the lucide icon with an inline SVG of the same size. Check each brand's usage rules, and keep the `aria-label` on the button so the tile still has a name.

Can the tiles be links?

Yes. Change each `button` to an `a` with an `href` and remove `aria-pressed`. The hover and focus handlers work the same on a link.

Adapted from Animated Frameworks in Eldora UI, used under its open-source licence. Licences and credits

Related: Animated Beam Connecting Integrations, Animated Bento Grid with Live Mini Demos, Bento Grid with Six Feature Tiles.

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

More in Animated Cards

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