Carousel Composer
alphav0.2.0Multi-item media post composer — drag in photos and videos, reorder them on a rail, and edit each through a shared editor panel.
Context
The authoring counterpart to the media-carousel viewer. Composes the shipped media-editor WITHOUT modifying it: a single editor instance is loaded serially with the selected item (never N at once, so it can't trip media-editor's multi-instance guard). The procomp owns the collection concerns — file intake (drop + multiple browse, MIME-inferred so there are no photo/video capture tabs), the ordered MediaCarouselItem model, the @dnd-kit reorderable thumbnail rail, the main preview, and the edit-panel lifecycle (Edit → mount media-editor in edit-only mode → flatten export back into the item). Edits flatten on apply so the rail/preview are always publish-ready; export() is pull-only. v0.1 intake is upload-only (library clamped) and video items are preview/reorder/remove + limited edit. Output is shaped to feed media-carousel. Primary downstream consumer: content-composer's post media slot (wired in content-composer v0.2 via the mediaCarouselSlot substrate).
Installation
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/carousel-composerAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/carousel-composer-fixturesCLI 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
Seeded from remote URLs (CMS re-edit). Select a thumb, drag to reorder, press Edit on any item. Videos open in video mode; a re-edit resumes from the exported frame.
Demo source
"use client"; import * as React from "react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { Button } from "@/components/ui/button";import { CarouselComposer } from "./carousel-composer";import type { CarouselComposerHandle, MediaCarouselItem,} from "./types";import { dummyCarouselItems } from "./dummy-data"; function Status({ children }: { children: React.ReactNode }) { return ( <p className="mt-3 font-mono text-xs text-muted-foreground">{children}</p> );} function EmptyTab() { const [count, setCount] = React.useState(0); return ( <div className="mx-auto max-w-md"> <CarouselComposer onChange={(items) => setCount(items.length)} editorProps={{ enabledTools: ["crop", "filters", "adjust", "text"] }} /> <Status> Drop or browse photos + videos. {count} item{count === 1 ? "" : "s"}. </Status> </div> );} function SeededTab() { return ( <div className="mx-auto max-w-md"> <CarouselComposer defaultValue={dummyCarouselItems} editorProps={{ enabledTools: ["crop", "filters", "adjust"] }} /> <Status> Seeded from remote URLs (CMS re-edit). Select a thumb, drag to reorder, press Edit on any item. Videos open in video mode; a re-edit resumes from the exported frame. </Status> </div> );} function ControlledTab() { const [items, setItems] = React.useState<MediaCarouselItem[]>([]); const ref = React.useRef<CarouselComposerHandle>(null); const [exported, setExported] = React.useState<number | null>(null); return ( <div className="mx-auto max-w-md"> <CarouselComposer ref={ref} value={items} onChange={setItems} /> <div className="mt-3 flex flex-wrap gap-2"> <Button size="sm" onClick={async () => { const out = await ref.current?.export(); setExported(out?.length ?? 0); }} > Export (publish) </Button> <Button size="sm" variant="ghost" onClick={() => ref.current?.reset()}> Reset </Button> </div> <Status> Controlled: {items.length} item{items.length === 1 ? "" : "s"} ·{" "} {items.filter((i) => i.kind === "image").length} photo /{" "} {items.filter((i) => i.kind === "video").length} video {exported !== null ? ` · last export → ${exported} items` : ""} </Status> </div> );} function MaxTab() { const [warn, setWarn] = React.useState<string | null>(null); return ( <div className="mx-auto max-w-md"> <CarouselComposer maxItems={3} defaultValue={dummyCarouselItems.slice(0, 2)} onMaxItemsExceeded={(attempted, max) => setWarn(`Tried ${attempted}, cap is ${max}.`) } /> <Status> Cap is 3. {warn ?? "Add more until the tile disappears."} </Status> </div> );} export default function CarouselComposerDemo() { const [tab, setTab] = React.useState("seeded"); return ( <Tabs value={tab} onValueChange={setTab} className="w-full"> <SwipeTabsList> <TabsTrigger value="empty">Empty</TabsTrigger> <TabsTrigger value="seeded">Seeded (re-edit)</TabsTrigger> <TabsTrigger value="controlled">Controlled + export</TabsTrigger> <TabsTrigger value="max">Max 3</TabsTrigger> </SwipeTabsList> <TabsContent value="empty" className="pt-4"> <EmptyTab /> </TabsContent> <TabsContent value="seeded" className="pt-4"> <SeededTab /> </TabsContent> <TabsContent value="controlled" className="pt-4"> <ControlledTab /> </TabsContent> <TabsContent value="max" className="pt-4"> <MaxTab /> </TabsContent> </Tabs> );} Usage
When to use
Reach for CarouselComposer when an author needs to assemble one or more media items into an ordered set — an Instagram-style feed post, an album, a product gallery, or chat attachments. It mixes photos and videos in a single rail, reorders by drag-and-drop, and edits any item through a shared media-editor panel. For a single hero image/video, use media-editor directly; to display a finished set, use media-carousel.
Basic example
import { CarouselComposer } from "@/components/carousel-composer"
export function PostMedia() {
const [items, setItems] = React.useState([])
return (
<CarouselComposer
value={items}
onChange={setItems}
maxItems={10}
editorProps={{ enabledTools: ["crop", "filters", "adjust"] }}
/>
)
}Pull-only export
Edits flatten into each item on Done, so the rail and preview always show publish-ready media. Call ref.current.export() at publish time to get the committed ordered array (an open, unapplied edit is excluded — gate your publish button while editing).
Notes
- Intake is file-based — the file's MIME type decides
imagevsvideo, so there are no photo/video capture tabs. - All items share one aspect (Instagram behaviour).
aspectdefaults to"auto"(derived from item 1); override with a fixedAspectRatio. - Editing mounts a single
media-editorinstance, loaded serially per item — never N at once. - v0.1: video items support preview / reorder / remove; full video editing tracks media-editor maturity.
"library"source is clamped to upload-only. - Object URLs created during intake/edit are revoked on remove, replace, reset, and unmount.
Features
- Multi-file intake — drag-and-drop dropzone + multiple Browse, mixed image + video (MIME-inferred, no capture-mode tabs)
- Ordered MediaCarouselItem model — add / remove / reorder / select
- Reorderable thumbnail rail (@dnd-kit horizontal sortable) with pointer + keyboard reorder and a dedicated drag handle
- Main preview of the selected item (image cover-fit; video on a black mat)
- Single shared media-editor edit panel — Edit pushes the selected item in, flattens the export back on Done, reloads the same panel for the next item
- Shared aspect across the carousel (Instagram behaviour) — `aspect="auto"` derives from item 1, overridable
- Controlled / uncontrolled value (value / defaultValue / onChange) + imperative handle (getItems / export / addFiles / removeItem / select / openEditor / reset)
- Pull-only export() — items already flattened on edit-apply
- Per-file type + size validation; maxItems cap (default 10, Instagram parity)
- Object-URL lifecycle managed (revoked on remove / replace / reset / unmount)