Skip to content
ilinxa/pro-ui

Media Carousel

alphav0.2.0

Embla image and video carousel — gallery or linear variants, coordinated video pause for inactive slides.

Category: MediaUpdated: 2026-08-11Created: 2026-05-02Author: ilinxa

Context

Use anywhere a swipeable strip of mixed images + videos is needed — Instagram-style post media (gallery), product galleries (linear), event photo strips, news article photo sets, real estate listings. The 'gallery' variant matches kasder's Instagram-post peek-scale-blur aesthetic exactly: focused image at center is full-bleed sharp; neighbors are scaled to 95%, opacity-60, and 1px-blurred. Soft edge gradients (background → transparent, 12 rem each side) further soften peek edges. The 'linear' variant is full-width snap (no peek, no scale). Single-item posts bypass the carousel entirely (no nav, no indicators, no scale). Built-in image + video handlers; renderItem slot for full per-slide takeover. **First cross-folder import in pro-ui's registry** — composes video-player directly for the built-in video handler. shadcn registryDependencies handles install. Migration origin: kasder kas-social-front-v0 PostMediaCarousel.tsx; third ship in the 8-component social-posts-system arc.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/media-carousel

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/media-carousel-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

Demo source

demo.tsxtsx
"use client"; import { useRef, useState } from "react";import { Button } from "@/components/ui/button";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { MediaCarousel } from "./media-carousel";import {  DUMMY_MIXED_MEDIA,  DUMMY_PRODUCT_PHOTOS,  DUMMY_SINGLE_IMAGE,  DUMMY_SINGLE_VIDEO,} from "./dummy-data";import type { MediaCarouselHandle } from "./types"; function ImperativeRefDemo() {  const ref = useRef<MediaCarouselHandle>(null);  const [currentIdx, setCurrentIdx] = useState(0);   return (    <div className="space-y-3">      <MediaCarousel        ref={ref}        items={DUMMY_MIXED_MEDIA}        variant="gallery"        onSlideChange={(idx) => setCurrentIdx(idx)}      />      <div className="flex flex-wrap items-center gap-2">        <span className="text-xs text-muted-foreground">          Active: <span className="font-mono">{currentIdx + 1}</span> /{" "}          {DUMMY_MIXED_MEDIA.length}        </span>        <Button          type="button"          size="sm"          variant="outline"          onClick={() => ref.current?.scrollPrev()}        >          Prev        </Button>        <Button          type="button"          size="sm"          variant="outline"          onClick={() => ref.current?.scrollNext()}        >          Next        </Button>        <Button          type="button"          size="sm"          variant="outline"          onClick={() => ref.current?.scrollTo(0)}        >          Jump to 1        </Button>        <Button          type="button"          size="sm"          variant="outline"          onClick={() => ref.current?.scrollTo(2)}        >          Jump to 3 (video)        </Button>      </div>    </div>  );} export default function MediaCarouselDemo() {  return (    <Tabs defaultValue="gallery">      <SwipeTabsList>        <TabsTrigger value="gallery">Gallery (mixed)</TabsTrigger>        <TabsTrigger value="linear">Linear (photos)</TabsTrigger>        <TabsTrigger value="single">Single-item shortcut</TabsTrigger>        <TabsTrigger value="custom">Custom renderItem</TabsTrigger>        <TabsTrigger value="imperative">Imperative ref</TabsTrigger>      </SwipeTabsList>       <TabsContent value="gallery" className="mt-6 space-y-3">        <MediaCarousel          items={DUMMY_MIXED_MEDIA}          variant="gallery"          onDoubleTap={(item, idx) =>            console.log("[demo] double-tap", item.id, idx)          }        />        <p className="text-xs text-muted-foreground">          Instagram-style peek-scale gallery. Double-tap any slide → console          log. The video at index 3 auto-pauses when you swipe to other          slides — that&apos;s the <code>isActive</code> contract from{" "}          <code>video-player</code>.        </p>      </TabsContent>       <TabsContent value="linear" className="mt-6 space-y-3">        <MediaCarousel          items={DUMMY_PRODUCT_PHOTOS}          variant="linear"          aspect="video"          loop={false}        />        <p className="text-xs text-muted-foreground">          Linear snap variant — full-width slides, no peek, no loop. Default          for product galleries / photo sets where each slide is independent.        </p>      </TabsContent>       <TabsContent value="single" className="mt-6 space-y-6">        <div className="space-y-2">          <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">            Single image (no nav, no indicators, no scale)          </p>          <MediaCarousel            items={DUMMY_SINGLE_IMAGE}            variant="gallery"            onDoubleTap={(item) => console.log("[demo] single tap", item.id)}          />        </div>        <div className="space-y-2">          <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">            Single video (no nav, no indicators, no scale)          </p>          <MediaCarousel items={DUMMY_SINGLE_VIDEO} variant="gallery" />        </div>      </TabsContent>       <TabsContent value="custom" className="mt-6 space-y-3">        <MediaCarousel          items={DUMMY_MIXED_MEDIA}          variant="gallery"          renderItem={(item, ctx) => (            <div className="relative h-full w-full">              {item.type === "image" ? (                <>                  <img                    src={item.url}                    alt={item.alt ?? ""}                    loading="lazy"                    className="h-full w-full object-cover"                  />                  <div className="absolute right-2 bottom-2 rounded-full bg-black/70 px-2 py-0.5 font-mono text-xs text-white tabular-nums">                    {ctx.index + 1} / {DUMMY_MIXED_MEDIA.length}                  </div>                </>              ) : (                <div className="flex h-full w-full items-center justify-center bg-muted">                  <span className="text-xs text-muted-foreground">                    [video item: custom render not implemented]                  </span>                </div>              )}            </div>          )}        />        <p className="text-xs text-muted-foreground">          The <code>renderItem</code> slot replaces both built-in handlers.          Here we draw a slide-counter overlay on each image. Video items          fall to the consumer&apos;s placeholder (in real apps, wire your          own video player here).        </p>      </TabsContent>       <TabsContent value="imperative" className="mt-6">        <ImperativeRefDemo />        <p className="mt-3 text-xs text-muted-foreground">          Imperative ref handle: <code>scrollTo(idx)</code> /{" "}          <code>scrollPrev()</code> / <code>scrollNext()</code> /{" "}          <code>getCurrentIndex()</code>. Use for programmatic navigation          (e.g., &quot;jump to media that has the comment user mentioned&quot;).        </p>      </TabsContent>    </Tabs>  );} 

Usage

When to use

Anywhere a swipeable horizontal strip of mixed images + videos is needed — Instagram-style post media (gallery), product galleries (linear), event photo strips, news article photo sets, real estate listings. The galleryvariant matches kasder's Instagram-post peek-scale aesthetic; the linear variant is full-width snap. Single-item posts bypass the carousel entirely (no nav, no indicators, no scale). Built-in image + video handlers; renderItem slot for full per-slide takeover.

Basic example

import { MediaCarousel } from "@/components/media-carousel";

export function PostMedia({ post }: { post: Post }) {
  return <MediaCarousel items={post.media} variant="gallery" />;
}

Linear variant (product gallery)

<MediaCarousel
  items={product.photos}
  variant="linear"
  aspect="video"
  loop={false}
/>

Cross-folder import: video items get full video-player

The built-in video item handler imports video-player directly. shadcn registryDependencies ensures both install when you run pnpm dlx shadcn@latest add @ilinxa/media-carousel. Videos in inactive slides pause cleanly via the isActive contract — no consumer wiring needed.

Custom renderItem slot

Replaces both built-in handlers. Receives (item, { isActive, index }) — consumer renders whatever (HLS player, 360° viewer, branded controls).

<MediaCarousel
  items={items}
  variant="gallery"
  renderItem={(item, { isActive, index }) =>
    item.type === "panorama" ? (
      <PanoramaViewer src={item.url} active={isActive} />
    ) : (
      <DefaultRenderer item={item} isActive={isActive} />
    )
  }
/>

Imperative ref handle

const carouselRef = useRef<MediaCarouselHandle>(null);

<MediaCarousel ref={carouselRef} items={items} variant="gallery" />
<button onClick={() => carouselRef.current?.scrollTo(2)}>
  Jump to slide 3
</button>

Double-tap-to-like integration

Unified onDoubleTap(item, index) fires for both image and video items. Use to trigger heart-burst overlays (composes with engagement-bar's EngagementHeartBurst sub-export) or analytics.

<MediaCarousel
  items={post.media}
  variant="gallery"
  onDoubleTap={(item, idx) => {
    setBurstKey((k) => k + 1);
    onLike(post.id);
    analytics.track("post.double_tap", { id: post.id, idx });
  }}
/>

onSlideChange for analytics

Fires only on Embla's selectevent (post-snap, not during drag). Mount-sync does NOT fire — first-render is silent so analytics consumers don't track a phantom "view slide 0" on every page load.

<MediaCarousel
  items={post.media}
  variant="gallery"
  onSlideChange={(idx) =>
    analytics.track("post.slide", { id: post.id, idx })
  }
/>

Localized labels

const TR_LABELS = {
  carouselLabel: "Medya galerisi",
  previousSlide: "Önceki",
  nextSlide: "Sonraki",
  goToSlide: "Slayta git",
  slideAriaLabel: "Slayt {index} / {total}",
} as const;

<MediaCarousel items={items} variant="gallery" labels={TR_LABELS} />

Anti-patterns

  • Don't add extra fields to MediaItem — strict discriminated union. Custom shapes go via renderItem with TS intersection.
  • Don't expect virtualization — kasder posts ≤10 media; v0.2 candidate (lazyVideoDistance) for larger sets.
  • Don't expect a fullscreen lightbox on slide-click — separate UX (would be image-lightbox-01).
  • Don't expect feature-strip variant in v0.1 — deferred (no concrete consumer in this scope; story-rail uses raw Embla).
  • Don't pass inline objects to labels: bust React.memo. Hoist to module scope.

Accessibility

  • Carousel root: role="region" + aria-roledescription="carousel" per WAI-ARIA APG.
  • Each slide: role="group" + aria-roledescription="slide" + aria-label="Slide N of M" (template configurable via labels.slideAriaLabel).
  • Indicator dots get aria-current="true" when active.
  • Embla provides arrow-key keyboard nav natively when the viewport has focus.
  • RTL: pass rtl={true} — Embla handles drag direction; chevrons flip via rtl:rotate-180.

Features

  • Two variants — gallery (Instagram peek-scale-blur) + linear (full-width snap)
  • Slide layout matches kasder verbatim — `mx-1 flex-[0_0_85%]` gutters; first/last `marginLeft/Right: peekRatio*100%` ONLY when loop=false (under loop, asymmetric edges break Embla clone math)
  • Inactive slide visuals — `scale-95 opacity-60 blur-[1px]` with 300ms transition (matches kasder + adds 1px softening blur)
  • Edge gradient overlays (gallery only) — `bg-linear-to-r/l from-background to-transparent w-12`, soft fade past the visible peek
  • Single-item shortcut — bypasses Embla entirely for items.length === 1 (separate component to honor React rules-of-hooks)
  • Built-in image handler via <img loading='lazy'>
  • Built-in video handler via <VideoPlayer> with isActive propagation (cross-folder import)
  • renderItem slot for full per-slide takeover (HLS, 360°, branded players)
  • Indicator dots — bottom-center, active dot elongates, click-to-jump, aria-current
  • Side nav chevrons — RTL flip via rtl:rotate-180
  • Loop default = items.length > 1; Embla configures itself — no defensive option overrides
  • Per-variant config — peekRatio (gallery, default 0.075), aspect (linear, default 'square')
  • Imperative ref handle — scrollTo / scrollPrev / scrollNext / getCurrentIndex (stable identity via currentIndexRef)
  • Unified onDoubleTap(item, index) for both image + video items
  • onSlideChange fires only on Embla 'select' event (post-snap; mount-sync silent)
  • Embla options memoized — no re-init on render
  • RTL via Embla direction + chevron flip
  • WAI-ARIA APG carousel pattern (region / slide group / aria-current on indicators)
  • Keyboard nav — region focusable, ArrowLeft/Right scroll prev/next (RTL-aware), Home/End jump to first/last
  • Inactive slides get HTML5 `inert` — Tab skips inactive content, screen readers ignore it
  • i18n via 5-key labels object with {index}/{total} placeholders

Tags

media-carouselcarouselmediaemblagallery

Dependencies

shadcn primitives: button
npm peer deps: embla-carousel@^8.6.0, embla-carousel-react@^8.6.0, lucide-react@^1.11.0
internal: video-player