Innovations

Slideshow Hero

Auto-advancing full-bleed slideshow with Ken Burns zoom on the active slide, slide counter, dot pagination, prev/next arrow controls, pause-on-hover, and italic caption typography. Below the slideshow: circular avatar overlap, Cormorant italic name, optional credentials line, and frosted-glass link buttons over a dark backdrop.

Preview

Source

tsx
"use client";

import { useEffect, useRef, useState } from "react";
import {
  Instagram,
  Twitter,
  Youtube,
  Mail,
  Globe,
  ShoppingBag,
  PlayCircle,
  Linkedin,
  Music2,
  ArrowUpRight,
  ChevronLeft,
  ChevronRight,
  Pause,
  Play,
  type LucideIcon,
} from "lucide-react";
import type { GalleryItem, LinkIcon, LinksInBioData } from "../types";
import { defaultData } from "../defaultData";

const ICONS: Record<LinkIcon, LucideIcon> = {
  instagram: Instagram,
  twitter: Twitter,
  tiktok: Music2,
  youtube: Youtube,
  spotify: Music2,
  apple: Music2,
  linkedin: Linkedin,
  email: Mail,
  globe: Globe,
  shop: ShoppingBag,
  play: PlayCircle,
};

const SLIDE_MS = 5500;

export interface LinksInBioSlideshowHeroProps {
  data?: LinksInBioData;
}

export default function LinksInBioSlideshowHero({
  data = defaultData,
}: LinksInBioSlideshowHeroProps) {
  const slides: GalleryItem[] =
    data.gallery && data.gallery.length > 0
      ? data.gallery
      : [{ image: data.heroImage ?? data.avatar, caption: data.name }];

  const [active, setActive] = useState(0);
  const [paused, setPaused] = useState(false);
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);

  useEffect(() => {
    if (paused || slides.length < 2) return;
    timerRef.current = setInterval(() => {
      setActive((a) => (a + 1) % slides.length);
    }, SLIDE_MS);
    return () => {
      if (timerRef.current) clearInterval(timerRef.current);
    };
  }, [paused, slides.length]);

  const prev = () => setActive((a) => (a - 1 + slides.length) % slides.length);
  const next = () => setActive((a) => (a + 1) % slides.length);

  return (
    <>
      <link rel="preconnect" href="https://fonts.googleapis.com" />
      <link
        rel="stylesheet"
        href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400..700;1,400..700&family=Inter:wght@400;500;600;700&display=swap"
      />
      <style>{`
        @keyframes slideshowhero-kenburns {
          from { transform: scale(1.02) translate3d(0,0,0); }
          to   { transform: scale(1.12) translate3d(-1.5%, -1%, 0); }
        }
      `}</style>

      <section
        className="min-h-screen w-full bg-[#0c0c0e] text-white"
        style={{ fontFamily: "'Inter', sans-serif" }}
      >
        {/* Slideshow */}
        <div
          className="relative h-[60vh] min-h-[420px] w-full overflow-hidden"
          onMouseEnter={() => setPaused(true)}
          onMouseLeave={() => setPaused(false)}
        >
          {slides.map((slide, i) => (
            <div
              key={i}
              aria-hidden={i !== active}
              className="absolute inset-0 transition-opacity duration-700"
              style={{ opacity: i === active ? 1 : 0 }}
            >
              <img
                src={slide.image}
                alt={slide.caption ?? ""}
                width={1600}
                height={2000}
                loading={i === 0 ? "eager" : "lazy"}
                className="h-full w-full object-cover"
                style={{
                  animation:
                    i === active && !paused
                      ? `slideshowhero-kenburns ${SLIDE_MS + 1500}ms ease-out forwards`
                      : "none",
                  willChange: "transform",
                }}
              />
            </div>
          ))}

          {/* Bottom gradient */}
          <div
            aria-hidden
            className="absolute inset-0"
            style={{
              background:
                "linear-gradient(180deg, rgba(12,12,14,0) 35%, rgba(12,12,14,0.95) 100%)",
            }}
          />

          {/* Slide counter & caption — top-left */}
          <div className="absolute left-5 top-5 flex items-center gap-3 text-xs">
            <span
              className="rounded-full border border-white/30 bg-black/30 px-3 py-1 font-mono backdrop-blur-md"
            >
              {String(active + 1).padStart(2, "0")} / {String(slides.length).padStart(2, "0")}
            </span>
            {slides[active]?.caption && (
              <span
                className="text-[11px] font-semibold uppercase tracking-[0.3em] text-white/85"
              >
                {slides[active].caption}
              </span>
            )}
          </div>

          {/* Pause/Play */}
          <button
            type="button"
            aria-label={paused ? "Play slideshow" : "Pause slideshow"}
            onClick={() => setPaused((p) => !p)}
            className="absolute right-5 top-5 flex h-9 w-9 items-center justify-center rounded-full border border-white/30 bg-black/30 text-white backdrop-blur-md hover:bg-black/50"
          >
            {paused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
          </button>

          {/* Prev/Next arrows */}
          <button
            type="button"
            aria-label="Previous slide"
            onClick={prev}
            className="absolute left-3 top-1/2 hidden h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/20 bg-black/25 text-white backdrop-blur-md transition-colors hover:bg-black/45 sm:flex"
          >
            <ChevronLeft className="h-5 w-5" />
          </button>
          <button
            type="button"
            aria-label="Next slide"
            onClick={next}
            className="absolute right-3 top-1/2 hidden h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/20 bg-black/25 text-white backdrop-blur-md transition-colors hover:bg-black/45 sm:flex"
          >
            <ChevronRight className="h-5 w-5" />
          </button>

          {/* Caption block bottom */}
          {slides[active]?.meta && (
            <p
              className="absolute inset-x-5 bottom-24 text-center text-sm italic text-white/90 sm:bottom-28"
              style={{ fontFamily: "'Cormorant Garamond', serif" }}
            >
              "{slides[active].meta}"
            </p>
          )}

          {/* Dots */}
          <div className="absolute inset-x-0 bottom-6 flex items-center justify-center gap-2">
            {slides.map((_, i) => (
              <button
                key={i}
                type="button"
                aria-label={`Slide ${i + 1}`}
                onClick={() => setActive(i)}
                className="rounded-full transition-all"
                style={{
                  width: i === active ? 22 : 8,
                  height: 8,
                  background:
                    i === active ? "#fff" : "rgba(255,255,255,0.45)",
                }}
              />
            ))}
          </div>
        </div>

        {/* Avatar overlap + identity */}
        <div className="relative mx-auto -mt-14 w-full max-w-[460px] px-5 pb-12">
          <div className="flex flex-col items-center text-center">
            <img
              src={data.avatar}
              alt={data.name}
              width={128}
              height={128}
              loading="eager"
              className="h-28 w-28 rounded-full object-cover"
              style={{
                border: "5px solid #0c0c0e",
                boxShadow: "0 14px 32px rgba(0,0,0,0.45)",
              }}
            />
            <h1
              className="mt-4 leading-tight text-white"
              style={{
                fontFamily: "'Cormorant Garamond', serif",
                fontStyle: "italic",
                fontSize: "clamp(2.2rem, 6.4vw, 2.8rem)",
              }}
            >
              {data.name}
              {data.verified && (
                <span className="ml-2 align-middle text-base not-italic text-[#c9a961]">✓</span>
              )}
            </h1>
            {data.handle && (
              <p className="mt-1 text-sm text-white/55">{data.handle}</p>
            )}
            {data.credentials && (
              <p className="mt-1 text-[11px] uppercase tracking-[0.28em] text-[#c9a961]">
                {data.credentials}
              </p>
            )}
            {data.bio && (
              <p className="mt-3 max-w-[400px] text-[15px] leading-relaxed text-white/75">
                {data.bio}
              </p>
            )}
          </div>

          <div className="mt-7 space-y-2.5">
            {data.links.map((link) => {
              const Icon = link.icon ? ICONS[link.icon] : ArrowUpRight;
              return (
                <a
                  key={link.label}
                  href={link.href}
                  className="group flex items-center gap-3 rounded-2xl border border-white/15 bg-white/[0.04] px-4 py-3.5 backdrop-blur-md transition-all hover:border-white/30 hover:bg-white/[0.08]"
                >
                  <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/10">
                    <Icon className="h-4 w-4 text-white" strokeWidth={1.6} />
                  </span>
                  <span className="flex-1 truncate text-sm font-medium text-white">
                    {link.label}
                  </span>
                  {link.badge && (
                    <span
                      className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider"
                      style={{ background: "#c9a961", color: "#0c0c0e" }}
                    >
                      {link.badge}
                    </span>
                  )}
                  <ArrowUpRight className="h-4 w-4 shrink-0 text-white/50 transition-colors group-hover:text-white" />
                </a>
              );
            })}
          </div>

          {data.socials && data.socials.length > 0 && (
            <div className="mt-7 flex items-center justify-center gap-4 text-white/65">
              {data.socials.map((s, i) => {
                const Icon = ICONS[s.type] ?? Globe;
                return (
                  <a
                    key={i}
                    href={s.href}
                    aria-label={s.type}
                    className="rounded-full border border-white/15 p-2 transition-colors hover:text-white"
                  >
                    <Icon className="h-4 w-4" strokeWidth={1.6} />
                  </a>
                );
              })}
            </div>
          )}
        </div>
      </section>
    </>
  );
}
Claude Code Instructions

CLI Install

npx innovations add slideshow-hero

Where to use it

An editorial slideshow link-in-bio page. Hardcoded dark palette with gold accent. Loads Cormorant Garamond and Inter from Google Fonts. Pass a typed 'data' prop (LinksInBioData from src/registry/links-in-bio/types.ts). Provide: - gallery — array of { image, caption, meta } where caption is the slide label (e.g., 'Lisbon') and meta is the italic quote shown beneath - credentials — short line beneath the name, rendered in gold uppercase tracking In Astro: import LinksInBioSlideshowHero from '../components/innovations/links-in-bio/slideshow-hero'; <LinksInBioSlideshowHero client:load data={myProfile} /> In Next.js: import LinksInBioSlideshowHero from '@/components/innovations/links-in-bio/slideshow-hero'; Best for: photographers, models, lifestyle creators, hospitality brands, dancers — anyone whose visual portfolio is the product. The slideshow auto-advances every 5.5s; users can pause via the top-right button.