Media Carousel
alphav0.2.0Embla image and video carousel — gallery or linear variants, coordinated video pause for inactive slides.
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
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/media-carouselAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/media-carousel-fixturesPreview




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's the isActive contract from video-player.
Demo source
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 viarenderItemwith 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-stripvariant in v0.1 — deferred (no concrete consumer in this scope; story-rail uses raw Embla). - Don't pass inline objects to
labels: bustReact.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 vialabels.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 viartl: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