Domphy

imageSlider

A Overlays block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call imageSlider() with no arguments for a working demo, or edit the code below live.

Implementation notes

Full-bleed autoplay (5s default) + arrow-key navigation (restarts autoplay timer) + dark overlay + centered content, all implemented. Uses a keyed reactive-array swap (div: (l) => [buildSlideLayer(...)]) with the motion() patch for the 3D scale/rotateX entrance and directional (up/down) translateY exit. motion()'s API only supports one shared transition.duration for both enter and exit, so this uses a single ~650ms compromise rather than the spec's differing ~500ms enter / ~1000ms exit numbers (which were themselves flagged 'good-confidence approximation, not exact upstream').

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Images Slider" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// full-bleed background image slider that autoplays and responds to
// keyboard navigation, transitioning slides with a subtle 3D tilt-and-scale
// entrance.
//
// The "AnimatePresence swap" the spec describes maps directly onto Domphy's
// own keyed-list reconciliation: the image layer's content is a reactive
// function returning a single-element array keyed by the active index
// (`div: (listener) => [buildSlideLayer(activeIndex.get(listener))]`, the
// exact pattern `@domphy/ui`'s own motion docs use for enter/exit swaps).
// Changing the key removes the previous slide (playing its `motion()` exit
// keyframe — a translateY slide fully off-screen) while inserting the new
// one (playing its enter keyframe — scale/opacity/rotateX settling to
// flat), both live in the DOM at once for the crossover, no manual
// mount/unmount bookkeeping needed. `motion()`'s single shared
// `transition.duration` (this component uses ~650ms) is the honest
// approximation of the spec's differing enter (~500ms) / exit (~1000ms)
// numbers, which the patch's single-transition-per-instance API can't split.

import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { button, heading, motion } from "@domphy/ui";
import { themeColor, themeSpacing } from "@domphy/theme";

export type ImageSliderExitDirection = "up" | "down";

export interface ImageSliderProps {
  /** Background image URLs, cycled in order. Defaults to 3 generic placeholder photos. */
  images?: string[];
  /** Content rendered centered above the overlay (heading, button, …). Defaults to a generic demo caption. */
  children?: DomphyElement | DomphyElement[];
  /** Dark semi-transparent legibility layer over the image. Defaults to `true`. */
  overlay?: boolean;
  /** Passthrough style merged onto the overlay layer. */
  overlayStyle?: StyleObject;
  /** Auto-advances every `intervalMs`. Defaults to `true`. */
  autoplay?: boolean;
  /** Milliseconds between automatic slide changes. Defaults to `5000`. */
  intervalMs?: number;
  /** Which way the outgoing slide exits. Defaults to `"up"`. */
  direction?: ImageSliderExitDirection;
  /** Passthrough style merged onto the outer container. */
  style?: StyleObject;
}

function placeholderSlide(paletteIndex: number): string {
  const palettes = [
    { top: "#0f172a", accent: "#38bdf8" },
    { top: "#1e293b", accent: "#f472b6" },
    { top: "#111827", accent: "#facc15" },
  ];
  const palette = palettes[paletteIndex % palettes.length]!;
  const markup =
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500">' +
    `<rect width="800" height="500" fill="${palette.top}"/>` +
    `<circle cx="620" cy="140" r="90" fill="${palette.accent}"/>` +
    `<path d="M0 380 L220 220 L360 320 L520 180 L800 360 L800 500 L0 500 Z" fill="rgba(255,255,255,0.08)"/>` +
    "</svg>";
  return `data:image/svg+xml,${encodeURIComponent(markup)}`;
}

const DEFAULT_IMAGES: string[] = [placeholderSlide(0), placeholderSlide(1), placeholderSlide(2)];

function defaultSliderContent(): DomphyElement[] {
  return [
    { h2: "Explore the World", $: [heading({ color: "neutral" })] } as DomphyElement,
    {
      button: "Get Started",
      $: [button({ color: "primary" })],
    } as DomphyElement<"button">,
  ];
}

function buildSlideLayer(
  index: number,
  imageSource: string,
  exitDirection: ImageSliderExitDirection,
): DomphyElement<"div"> {
  return {
    div: null,
    ariaHidden: "true",
    _doctorDisable: "missing-color",
    _key: `image-slider-slide-${index}`,
    style: {
      position: "absolute",
      inset: 0,
      backgroundImage: `url(${imageSource})`,
      backgroundSize: "cover",
      backgroundPosition: "center",
    } as StyleObject,
    $: [
      motion({
        initial: { opacity: 0, transform: "scale(0) rotateX(45deg)" },
        animate: { opacity: 1, transform: "scale(1) rotateX(0deg)" },
        exit: { y: exitDirection === "up" ? "-150%" : "150%" },
        transition: { duration: 650, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
      }),
    ],
  } as DomphyElement<"div">;
}

/**
 * A full-bleed autoplaying background image slider with keyboard navigation
 * and a subtle 3D tilt-and-scale slide transition. Call with no arguments
 * for a working demo with 3 generic placeholder photos.
 */
function imageSlider(props: ImageSliderProps = {}): DomphyElement<"div"> {
  const images = props.images && props.images.length > 0 ? props.images : DEFAULT_IMAGES;
  const totalSlides = images.length;
  const showOverlay = props.overlay ?? true;
  const autoplay = props.autoplay ?? true;
  const intervalMs = Math.max(500, props.intervalMs ?? 5000);
  const exitDirection = props.direction ?? "up";
  const content = props.children
    ? Array.isArray(props.children)
      ? props.children
      : [props.children]
    : defaultSliderContent();

  const activeIndexState = toState(0, "image-slider-active-index");

  const imageLayerWrapper: DomphyElement<"div"> = {
    div: (listener: Listener) => {
      const index = activeIndexState.get(listener);
      return [buildSlideLayer(index, images[index] ?? images[0]!, exitDirection)];
    },
    style: { position: "absolute", inset: 0, zIndex: 0, overflow: "hidden", perspective: "1200px" } as StyleObject,
  } as DomphyElement<"div">;

  const overlayLayer: DomphyElement<"div"> | null = showOverlay
    ? ({
        div: null,
        ariaHidden: "true",
        dataTone: "shift-17",
        style: {
          position: "absolute",
          inset: 0,
          zIndex: 1,
          backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
          color: (listener: Listener) => themeColor(listener, "shift-9"),
          opacity: 0.55,
          pointerEvents: "none",
          ...(props.overlayStyle ?? {}),
        } as StyleObject,
      } as DomphyElement<"div">)
    : null;

  const contentLayer: DomphyElement<"div"> = {
    div: content,
    style: {
      position: "relative",
      zIndex: 2,
      display: "flex",
      flexDirection: "column",
      alignItems: "center",
      justifyContent: "center",
      height: "100%",
      textAlign: "center",
      gap: themeSpacing(4),
    } as StyleObject,
  } as DomphyElement<"div">;

  return {
    div: [imageLayerWrapper, ...(overlayLayer ? [overlayLayer] : []), contentLayer],
    tabindex: 0,
    style: {
      position: "relative",
      overflow: "hidden",
      width: "100%",
      height: "100dvh",
      outline: "none",
      ...(props.style ?? {}),
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      if (typeof window === "undefined") return;
      const containerElement = node.domElement as HTMLElement | null;
      if (!containerElement) return;

      let intervalId: ReturnType<typeof setInterval> | null = null;
      let intersectionObserver: IntersectionObserver | null = null;

      const goToIndex = (nextIndex: number) => {
        const wrapped = ((nextIndex % totalSlides) + totalSlides) % totalSlides;
        activeIndexState.set(wrapped);
      };
      const goNext = () => goToIndex(activeIndexState.get() + 1);
      const goPrevious = () => goToIndex(activeIndexState.get() - 1);

      const stopAutoplay = () => {
        if (intervalId === null) return;
        clearInterval(intervalId);
        intervalId = null;
      };
      const restartAutoplay = () => {
        stopAutoplay();
        if (autoplay && totalSlides > 1) intervalId = setInterval(goNext, intervalMs);
      };

      const handleKeyDown = (event: KeyboardEvent) => {
        if (event.key === "ArrowRight" || event.key === "ArrowDown") {
          event.preventDefault();
          goNext();
          restartAutoplay();
        } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
          event.preventDefault();
          goPrevious();
          restartAutoplay();
        }
      };
      window.addEventListener("keydown", handleKeyDown);

      if (typeof IntersectionObserver === "function") {
        intersectionObserver = new IntersectionObserver((entries) => {
          for (const entry of entries) {
            if (entry.isIntersecting) restartAutoplay();
            else stopAutoplay();
          }
        });
        intersectionObserver.observe(containerElement);
      } else {
        restartAutoplay();
      }

      node.addHook("Remove", () => {
        stopAutoplay();
        intersectionObserver?.disconnect();
        window.removeEventListener("keydown", handleKeyDown);
      });
    },
  } as DomphyElement<"div">;
}

export { imageSlider };

← Back to Aceternity UI catalog