Skip to content
ilinxa/pro-ui

Carousel Composer

alphav0.2.0

Multi-item media post composer — drag in photos and videos, reorder them on a rail, and edit each through a shared editor panel.

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

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

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/carousel-composer

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/carousel-composer-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

ridge-light.jpg

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

demo.tsxtsx
"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 image vs video, so there are no photo/video capture tabs.
  • All items share one aspect (Instagram behaviour). aspect defaults to "auto" (derived from item 1); override with a fixed AspectRatio.
  • Editing mounts a single media-editor instance, 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)

Tags

carousel-composercarouselgallerymediauploadereditordrag-and-dropinstagram

Dependencies

shadcn primitives: button, scroll-area
npm peer deps: @dnd-kit/core@^6.3.1, @dnd-kit/sortable@^10.0.0, @dnd-kit/utilities@^3.2.2, lucide-react@^1.11.0
internal: media-editor