logo-timeline

PreviousNext

An animated logo timeline component with horizontal scrolling logos.

Docs
eldorauiui

Preview

Loading preview…
registry/eldoraui/logo-timeline.tsx
"use client"

import type React from "react"
import { useState } from "react"
import { motion } from "motion/react"

import { cn } from "@/lib/utils"
import { Icons } from "@/components/icons"

export interface LogoItem {
  /** The label text displayed next to the icon */
  label: string
  /** The icon name from the Icons object (e.g., "gitHub", "react", "tailwind") */
  icon: keyof typeof Icons
  /** Animation delay in seconds (use negative values for staggered effect) */
  animationDelay: number
  /** Animation duration in seconds */
  animationDuration: number
  /** The row number where this logo should appear (1-based) */
  row: number
}

export interface LogoTimelineProps {
  /** Array of logo items to display */
  items: LogoItem[]
  /** Optional title text to display in the center */
  title?: string
  /** Height of the timeline container */
  height?: string
  /** Additional className for the container */
  className?: string
  /** Icon size in pixels (default: 16) */
  iconSize?: number
  /** Whether to show separator lines between rows (default: true) */
  showRowSeparator?: boolean
  /** Whether to animate logos only on hover (default: false) */
  animateOnHover?: boolean
}

export function LogoTimeline({
  items,
  title,
  height = "h-[400px] sm:h-[800px]",
  className,
  iconSize = 16,
  showRowSeparator = true,
  animateOnHover = false,
}: LogoTimelineProps) {
  const [isHovered, setIsHovered] = useState(false)

  // Group items by row
  const rowsMap = new Map<number, LogoItem[]>()
  items.forEach((item) => {
    if (!rowsMap.has(item.row)) {
      rowsMap.set(item.row, [])
    }
    rowsMap.get(item.row)?.push(item)
  })

  // Convert map to sorted array
  const rows = Array.from(rowsMap.entries())
    .sort(([a], [b]) => a - b)
    .map(([, rowItems]) => rowItems)

  // Determine animation play state
  const animationPlayState = animateOnHover
    ? isHovered
      ? "running"
      : "paused"
    : "running"

  const getIconComponent = (iconName: keyof typeof Icons) => {
    const Icon = Icons[iconName]
    if (!Icon) {
      return null
    }
    return (
      <Icon
        className="shrink-0"
        style={{ width: iconSize, height: iconSize }}
        aria-hidden="true"
      />
    )
  }

  return (
    <section className={cn("w-full", height, className)}>
      <motion.div
        aria-hidden="true"
        className="bg-background relative h-full w-full overflow-hidden py-24 ring-inset sm:py-32"
        onMouseEnter={() => animateOnHover && setIsHovered(true)}
        onMouseLeave={() => animateOnHover && setIsHovered(false)}
      >
        {title && (
          <div className="absolute top-1/2 left-1/2 mx-auto w-full max-w-[90%] -translate-x-1/2 -translate-y-1/2 text-center">
            <div className="relative z-10">
              <p className="text-foreground/10 mx-auto mt-2 max-w-3xl text-4xl font-semibold tracking-tight text-pretty sm:text-5xl md:text-6xl">
                {title}
              </p>
            </div>
          </div>
        )}
        <div
          className="[container-type:inline-size] absolute inset-0 grid"
          style={{ gridTemplateRows: `repeat(${rows.length}, 1fr)` }}
        >
          {rows.map((rowItems, index) => (
            <div className="group relative flex items-center" key={index}>
              <div className="from-foreground/15 dark:from-foreground/15 absolute inset-x-0 top-1/2 h-0.5 bg-gradient-to-r from-[2px] to-[2px] bg-[length:12px_100%]" />
              {showRowSeparator && (
                <div className="from-foreground/5 dark:from-foreground/5 absolute inset-x-0 bottom-0 h-0.5 bg-gradient-to-r from-[2px] to-[2px] bg-[length:12px_100%] group-last:hidden" />
              )}
              {rowItems.map((logo) => {
                const IconComponent = getIconComponent(logo.icon)
                if (!IconComponent) {
                  return null
                }
                return (
                  <div
                    key={`${logo.row}-${logo.label}`}
                    className={cn(
                      "absolute top-1/2 flex -translate-y-1/2 items-center gap-2 px-3 py-1.5 whitespace-nowrap",
                      "from-secondary/50 to-secondary/50 ring-background/10 dark:ring-foreground/10 rounded-full bg-gradient-to-t from-50% ring-1 backdrop-blur-sm ring-inset",
                      "[--move-x-from:-100%] [--move-x-to:calc(100%+100cqw)] [animation-iteration-count:infinite] [animation-name:move-x] [animation-timing-function:linear]",
                      "shadow-[0_0_15px_rgba(0,0,0,0.1)] dark:shadow-none"
                    )}
                    style={{
                      animationDelay: `${logo.animationDelay}s`,
                      animationDuration: `${logo.animationDuration}s`,
                      animationPlayState: animationPlayState,
                    }}
                  >
                    {IconComponent}
                    <span className="text-foreground text-sm/6 font-medium">
                      {logo.label}
                    </span>
                  </div>
                )
              })}
            </div>
          ))}
        </div>
      </motion.div>
    </section>
  )
}

Installation

npx shadcn@latest add @eldoraui/logo-timeline

Usage

import { LogoTimeline } from "@/components/ui/logo-timeline"
<LogoTimeline />