Moving Card

Moving cards on website

Moving Card

This is a horizontal "marquee" of cards that scrolls on its own, the kind of moving testimonial or tweet wall you see on landing pages. The React component fetches a list of tweets from an API and renders each one as a styled card, and a CSS keyframe animation slides the whole track sideways in an endless loop. I use this pattern when I want social proof to feel alive without requiring the visitor to scroll or interact.

Card Design

import React from "react";
import Link from "next/link";
import Image from "next/image";
import Styles from "./Moving.module.scss";
import axios from "axios";
export default function MovingCard() {
  function TwitterCard({
    tweet_id,
    tweet_by,
    tweet_text,
    profile_url,
    avatar_image_url,
    tweet_by_bio,
  }) {
    return (
      <div className={Styles.card}>
        <div
          style={{
            display: "flex",
            flexDirection: "row",
            justifyContent: "space-between",
            alignItems: "center",
            marginBottom: "20px",
            elevation: "1",
          }}
        >
          <a
            target="_blank"
            href={profile_url}
            style={{
              textDecoration: "none",
            }}
          >
            <div
              style={{
                display: "flex",
                flexDirection: "row",
                justifyContent: "space-between",
                alignItems: "center",
              }}
            >
              <div
                style={{
                  display: "flex",
                  flexDirection: "column",
                  justifyContent: "space-between",
                  alignItems: "center",
                }}
              >
                <div
                  style={{
                    display: "flex",
                    flexDirection: "row",
                    justifyContent: "space-between",
                    alignItems: "center",
                  }}
                >
                  <img
                    src={avatar_image_url}
                    alt={tweet_by}
                    style={{
                      width: "40px",
                      height: "40px",
                      objectFit: "cover",
                      borderRadius: "50%",
                    }}
                  />
                </div>
              </div>
              <div
                style={{
                  display: "flex",
                  flexDirection: "column",
                  justifyContent: "space-between",
                  alignItems: "left",
                }}
              >
                <h4
                  style={{
                    color: "#000",
                    fontWeight: "bold",
                    fontSize: "14px",
                    margin: "10px",
                    padding: "0",
                  }}
                >
                  {tweet_by}
                </h4>
                <h5
                  style={{
                    color: "#555770",
                    fontSize: "12px",
                    margin: "10px",
                    padding: "0",
                  }}
                >
                  {tweet_by_bio}
                </h5>
              </div>
              <div
                style={{
                  display: "flex",
                  flexDirection: "column",
                  justifyContent: "space-between",
                  alignItems: "center",
                }}
              >
                <svg
                  viewBox="0 0 24 24"
                  aria-hidden="true"
                  style={{
                    width: "24px",
                    height: "24px",
                    color: "#1da1f2",
                  }}
                >
                  <g>
                    <path d="M23.643 4.937c-.835.37-1.732.62-2.675.733.962-.576 1.7-1.49 2.048-2.578-.9.534-1.897.922-2.958 1.13-.85-.904-2.06-1.47-3.4-1.47-2.572 0-4.658 2.086-4.658 4.66 0 .364.042.718.12 1.06-3.873-.195-7.304-2.05-9.602-4.868-.4.69-.63 1.49-.63 2.342 0 1.616.823 3.043 2.072 3.878-.764-.025-1.482-.234-2.11-.583v.06c0 2.257 1.605 4.14 3.737 4.568-.392.106-.803.162-1.227.162-.3 0-.593-.028-.877-.082.593 1.85 2.313 3.198 4.352 3.234-1.595 1.25-3.604 1.995-5.786 1.995-.376 0-.747-.022-1.112-.065 2.062 1.323 4.51 2.093 7.14 2.093 8.57 0 13.255-7.098 13.255-13.254 0-.2-.005-.402-.014-.602.91-.658 1.7-1.477 2.323-2.41z"></path>
                  </g>
                </svg>
              </div>
            </div>
            <div
              style={{
                display: "flex",
                flexDirection: "row",
                justifyContent: "space-between",
                alignItems: "center",
                marginTop: "1rem",
                fontSize: "1rem",
              }}
            >
              <p
                style={{
                  color: "#555770",
                  fontSize: "14px",
                  margin: "0",
                  padding: "0",
                }}
              >
                {tweet_text}
              </p>
            </div>
          </a>
        </div>
      </div>
    );
  }

  const [data, setData] = React.useState([]);

  React.useEffect(() => {
    axios
      .get("https://fastapi-demo-carson.herokuapp.com/tweets")
      .then((res) => {
        setData(res.data);
      })
      .catch((err) => {
        console.log(err);
      });
  }, []);

  return (
    <div className={Styles.slider}>
      <div className={Styles.slideTrack}>
        {data.map((item) => {
          return (
            <div className={Styles.slide}>
              {
                <TwitterCard
                  tweet_id={item.tweet_id}
                  tweet_by={item.tweet_by}
                  tweet_text={item.tweet_text}
                  profile_url={item.profile_url}
                  avatar_image_url={item.avatar_image_url}
                  tweet_by_bio={item.tweet_by_bio}
                />
              }
            </div>
          );
        })}
      </div>
    </div>
  );
}

Controlling CSS

$animationSpeed: 20s;

// Animation
@keyframes scroll {
  100% {
    transform: translateX(calc(-310px * 5));
  }
}

// Styling
.slider {
  background: rgba(0, 0, 0, 0.125);
  box-shadow: 0 10px 20px -5px rgba(0, 0, 0, 0.125);
  height: 100%;
  margin: auto;
  overflow: hidden;
  position: relative;
  padding: 20px 20px;

  .slideTrack {
    animation: scroll $animationSpeed linear none infinite;
    display: flex;
  }

  .slide {
    min-width: 300px;
    margin-left: 10px;
  }
}

.card {
  background: #fff;
  border-radius: 5px;
  box-shadow: 0 10px 20px -5px rgba(0, 0, 0, 0.125);
  height: 100%;
  padding: 20px;
}

How it works

The component has two parts. TwitterCard is a presentational component that lays out a single tweet, avatar, name, bio, the Twitter logo svg, and the tweet text, using inline styles for the flex layout. The parent MovingCard holds the data in state, fetches the tweets once on mount via axios inside a useEffect with an empty dependency array, and then maps each item to a slide wrapper containing a TwitterCard.

The motion is pure CSS. The .slider is position: relative with overflow: hidden, so anything wider than it is clipped, that's the viewport for the marquee. Inside it, .slideTrack is a flex row that holds all the cards in a single long strip, and it carries animation: scroll $animationSpeed linear ... infinite. The scroll keyframe translates the track along the X axis by calc(-310px * 5), i.e. five card-widths (each .slide is min-width: 300px plus margin), moving the strip left until those cards have passed, then looping. linear keeps the scroll speed constant, and the SCSS variable $animationSpeed: 20s sets how long one full cycle takes.

Notes & variations

This snippet mixes a few dependencies: axios for fetching, Next.js Link/Image imports, and an SCSS module (Moving.module.scss) for the slider/slideTrack/slide/card classes, so it needs SCSS-module support in your build. The fetch points at a specific demo API (fastapi-demo-carson.herokuapp.com/tweets); swap that URL for your own endpoint, or replace the effect with static data.

The translate distance is hardcoded to -310px * 5, which assumes a known card width and count. For a seamless infinite loop you typically duplicate the list so the strip is at least twice the viewport width and animate by exactly the width of one copy, otherwise you'll see a visible jump or empty gap when the loop restarts, especially if the API returns a different number of items than the animation assumes. Tune $animationSpeed to control how fast it scrolls.

Two cleanups for production: the mapped .slide elements have no key prop, so add a stable key (for example item.tweet_id) to satisfy React and avoid reconciliation bugs. And because this is continuous, automatic motion, it's good practice to pause it on hover and to respect @media (prefers-reduced-motion: reduce) by stopping the animation for users who opt out of motion. Real alt text already comes from tweet_by on the avatar image, which is a nice touch to keep.

Live Demo