Story Composer
alphav0.4.1Instagram-style story creation surface — a locked 9:16 wrapper around the media editor.
Context
Third and final component in the story-system trilogy alongside story-rail (discovery) and story-viewer (consumption). v0.2.0 delegates the capture + edit surface (camera, multi-layer Konva editor, all six tools, discard guard, history) to @ilinxa/media-editor (v0.1.1+), then layers the story-specific publish pipeline on top: ComposerPublishBar in the renderTopBar slot, PublishingProgressOverlay during upload, and a 14-method handle whose publish/exportBlob methods bridge ExportMetadata → PublishMetadata → PublishedStory. Camera-first defaults: rear camera on mobile, front on desktop, microphone for video. Public API 100% preserved across the v0.1.5 → v0.2.0 boundary; the 73-name export snapshot at docs/procomps/media-editor-procomp/story-composer-v0.1.5-exports.snapshot.txt resolves through the v0.2.0 barrel without omission.
Installation
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/story-composerAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/story-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
All three modes (photo / video / text), 36 built-in emoji stickers. Wired to a fake uploader for the docs site — consumers pass `uploadUrl` or a real `uploader` for prod.
Demo source
"use client"; import { 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 { StoryComposer } from "./story-composer";import { SAMPLE_BRAND_STICKERS } from "./dummy-data";import type { PublishedStory } from "./types"; // Demo-site uploader — pretends to upload (1.5s) and returns the blob's own// object URL. Keeps every tab able to round-trip Publish → Done without a// real backend; consumers wire `uploadUrl` or their own `uploader` for prod.async function demoUploader(blob: Blob) { await new Promise((r) => setTimeout(r, 1500)); return { url: URL.createObjectURL(blob), thumbnailUrl: URL.createObjectURL(blob), };} export default function StoryComposerDemo() { return ( <Tabs defaultValue="default" className="flex flex-col gap-4"> <SwipeTabsList> <TabsTrigger value="default">Default</TabsTrigger> <TabsTrigger value="photo-only">Photo only</TabsTrigger> <TabsTrigger value="custom-stickers">Custom stickers</TabsTrigger> <TabsTrigger value="custom-uploader">Custom uploader</TabsTrigger> <TabsTrigger value="no-confirm">No discard guard</TabsTrigger> </SwipeTabsList> <TabsContent value="default"> <ComposerLauncher description="All three modes (photo / video / text), 36 built-in emoji stickers. Wired to a fake uploader for the docs site — consumers pass `uploadUrl` or a real `uploader` for prod." props={{ uploader: demoUploader }} /> </TabsContent> <TabsContent value="photo-only"> <ComposerLauncher description="hideModes={['video','text']} — photo-only flow (useful for product or content surfaces that don't accept video)." props={{ uploader: demoUploader, hideModes: ["video", "text"], }} /> </TabsContent> <TabsContent value="custom-stickers"> <ComposerLauncher description="Adds a consumer-supplied sticker set (Ilinxa brand) alongside the built-in catalog. Toggle replaceBuiltinStickers to ship consumer-only." props={{ uploader: demoUploader, stickers: [SAMPLE_BRAND_STICKERS], }} /> </TabsContent> <TabsContent value="custom-uploader"> <ComposerLauncher description="Custom async uploader — useful for S3 pre-signed PUT, Cloudinary direct upload, Mux. The composer hands the blob + metadata to your function and you return { url }." props={{ uploader: demoUploader }} /> </TabsContent> <TabsContent value="no-confirm"> <ComposerLauncher description="confirmOnDiscard={false} — silent discard on close, matching Instagram's behavior. Don't ship this in production unless you have a separate draft-recovery path." props={{ uploader: demoUploader, confirmOnDiscard: false }} /> </TabsContent> </Tabs> );} interface ComposerLauncherProps { description: string; props: Partial<React.ComponentProps<typeof StoryComposer>>;} function ComposerLauncher({ description, props }: ComposerLauncherProps) { const [open, setOpen] = useState(false); const [lastPublished, setLastPublished] = useState<PublishedStory | null>( null, ); return ( <div className="flex flex-col items-center gap-4 p-8"> <p className="max-w-md text-center text-sm text-muted-foreground"> {description} </p> <Button onClick={() => setOpen(true)}>Open composer</Button> <StoryComposer isOpen={open} onClose={() => setOpen(false)} onPublished={(story) => { setLastPublished(story); setOpen(false); }} {...props} /> {lastPublished ? ( <pre className="max-w-md overflow-x-auto rounded-md border border-border bg-muted p-3 font-mono text-xs"> {JSON.stringify(lastPublished, null, 2)} </pre> ) : null} </div> );} Usage
When to use
StoryComposer is the creation surface for Instagram-style stories. It composes with StoryRail (discovery) and StoryViewer(consumption) to form the full story system, but doesn't depend on them. Use it whenever you need camera-first capture + on-canvas editing + one-tap publish.
Quick start
import { useState } from "react"
import { StoryComposer } from "@/components/story-composer"
export function Example() {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>Create story</button>
<StoryComposer
isOpen={open}
onClose={() => setOpen(false)}
uploadUrl="/api/stories/upload"
onPublished={(story) => {
// story.items[0].src = the uploaded media URL
console.log("uploaded:", story)
setOpen(false)
}}
/>
</>
)
}Capture modes
- Photo — camera tap → instant editor with the default toolbar (Text / Draw / Stickers / Filters / Adjust). Crop is opt-in via
enabledTools={[…, "crop"]}— stories are 9:16-locked, so default flows don't need it. - Video — long-press hold OR tap-to-toggle shutter. Auto-stop at
maxVideoDuration(default 30s). Edit stage shows a two-handle trim bar; overlays bake into the final video on publish. - Text — 8 gradient backgrounds + centered text + font + color picker. Renders to PNG on publish.
Hide modes you don't want via hideModes={["video","text"]}.
Publish
Two paths — pick one:
uploadUrl="/api/upload"— composer POSTs FormData ({ file, metadata }) with progress events.uploader={async (blob, meta) => …}— custom uploader for signed-URL flows (S3 PUT, Cloudinary, Mux). Must return{ url, thumbnailUrl? }.
Pan + zoom
Drag (single pointer — mouse or 1 finger) to pan; a drag that starts on a text/sticker overlay moves that overlay instead, and a tap never pans. Zoom 1× → 4× via 2-finger pinch (touch) or mouse wheel anchored to the cursor (desktop — native non-passive, beats the browser's Ctrl+wheel page-zoom). Arrow keys pan in the arrow direction; + / - / 0 zoom in / out / reset. Disabled while drawing or cropping.
Imperative handle
Pass a ref to drive the composer programmatically: takePhoto, switchCamera, startRecording, addText, addSticker, applyFilter, publish, exportBlob, and more (14 methods total).
More
Full reference + slot extension points + accessibility notes live in docs/procomps/story-composer-procomp/story-composer-procomp-guide.md.
Features
- v0.4.1 — `editorBackground` is marked @notImplemented and dev-warns; it was declared and never applied
- Camera capture (photo + video + text-only modes) via getUserMedia + MediaRecorder
- Gallery picker fallback when camera is denied or unavailable
- Multi-layer Konva editor: image / drawing / stickers / text / UI
- Six edit tools — Text, Draw (vector + eraser), Stickers (36 built-in emoji + extensible), Filters (10 Instagram-style presets with pre-rendered thumbs), Adjust (brightness/contrast/saturation/blur), and opt-in Crop (9:16 / 1:1 / 4:5). Default `enabledTools` ships the first five; consumers add `"crop"` when their flow isn't 9:16-locked.
- Pan + zoom on the editor canvas (1×–4×): single-pointer drag-to-pan (mouse or 1-finger; yields to draggable text/sticker overlays), 2-finger touch pinch, mouse wheel anchored to cursor (native non-passive — beats browser page-zoom), keyboard arrows pan in image direction, +/-/0 zoom/reset
- Video record: long-press hold OR tap-to-toggle shutter; auto-stop at maxVideoDuration; two-handle trim bar; overlays bake into final video via canvas.captureStream + MediaRecorder pipeline
- Built-in upload via XHR POST FormData with progress, plus `uploader` escape hatch for signed-URL flows (S3 / Cloudinary / Mux)
- Responsive: mobile-fullscreen / desktop-modal sized to a viewport-relative 9:16 with a min/max clamp (can't collapse or overflow) and safe-area awareness; top bar follows the IG chrome model (X swaps to a back arrow in the edit stage; Publish hidden during photo/video capture)
- Undo/redo on every overlay + stroke command (Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z; 50-deep stack)
- Permission-denied auto-retry via navigator.permissions.query
- Discard-confirm guard for unsaved edits (opt-out via confirmOnDiscard: false)
- Live-region announcer for screen readers (Konva canvas is opaque to SR)
- Five exported sealed-folder parts + three exported hooks for advanced compositions
- 14-method imperative handle (open/close/reset, switchCamera/takePhoto/startRecording/stopRecording/importFromGallery, addText/addSticker/setAdjustments/applyFilter, publish/exportBlob)
- v0.2.0 architecture: thin wrapper around @ilinxa/media-editor — capture + edit surface delegated; story-shaped publish flow + ComposerPublishBar layered on top
- v0.4.0 — P3 feature-slicing lockstep: camera capture now flows through media-editor's `capture={mediaCapture}` extension (`@ilinxa/media-editor-capture`) instead of a static import; the v0.1.5 backward-compat re-exports scheduled for v0.3.0 removal (ComposerCamera/ComposerEditor/ComposerToolbar/ColorSwatchPicker + their Props types, and useMediaCapture/validateGalleryFile/suggestedVideoFilename/CapturedPhoto/CapturedVideo/CaptureStatus/FacingMode + their option types) are removed — breaking for any consumer still on the v0.1.5 names.