Skip to content
Animation

Animation

EdgeFadeMarquee

CSS marquee with faded edges that pauses on hover and stops for reduced motion.

npx bezel-add add edge-fade-marquee

packages/ui/src/animation/EdgeFadeMarquee.tsx · 106 lines

"use client";

import {
  useEffect,
  useState,
  type CSSProperties,
  type ReactNode,
} from "react";

type EdgeFadeMarqueeProps = {
  children: ReactNode;
  className?: string;
  trackClassName?: string;
  duration?: number;
  pauseOnHover?: boolean;
  fade?: boolean;
  fadeColor?: string;
  gap?: number;
};

export function EdgeFadeMarquee({
  children,
  className = "",
  trackClassName = "",
  duration = 40,
  pauseOnHover = true,
  fade = true,
  fadeColor = "rgba(238, 231, 251, 0.95)",
  gap = 16,
}: EdgeFadeMarqueeProps) {
  const [reduce, setReduce] = useState(false);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const apply = () => setReduce(mq.matches);
    apply();
    mq.addEventListener("change", apply);
    return () => mq.removeEventListener("change", apply);
  }, []);

  return (
    <>
      <style>{`
        .efm {
          overflow: hidden;
          position: relative;
        }
        .efm__fade {
          pointer-events: none;
          position: absolute;
          inset-block: 0;
          width: 48px;
          z-index: 2;
        }
        .efm__fade--l {
          left: 0;
          background: linear-gradient(90deg, var(--efm-fade), transparent);
        }
        .efm__fade--r {
          right: 0;
          background: linear-gradient(270deg, var(--efm-fade), transparent);
        }
        .efm__track {
          display: flex;
          width: max-content;
          animation: efm-marq var(--efm-dur, 40s) linear infinite;
        }
        .efm[data-pause="true"]:hover .efm__track {
          animation-play-state: paused;
        }
        .efm[data-static="true"] .efm__track {
          animation: none;
        }
        @keyframes efm-marq {
          to { transform: translate3d(-50%, 0, 0); }
        }
      `}</style>
      <div
        className={`efm ${className}`.trim()}
        data-pause={pauseOnHover ? "true" : "false"}
        data-static={reduce ? "true" : undefined}
        style={{ ["--efm-fade" as string]: fadeColor } as CSSProperties}
      >
        {fade && !reduce && (
          <>
            <span className="efm__fade efm__fade--l" aria-hidden />
            <span className="efm__fade efm__fade--r" aria-hidden />
          </>
        )}
        <div
          className={`efm__track ${trackClassName}`.trim()}
          style={
            {
              gap,
              ["--efm-dur" as string]: `${duration}s`,
              ...(reduce ? { animation: "none" } : null),
            } as CSSProperties
          }
        >
          {children}
          {!reduce && children}
        </div>
      </div>
    </>
  );
}