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
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
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

Preview

Demo source

demo.tsxtsx

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