Skip to content
ilinxa/pro-ui

Story Composer

alphav0.4.1

Instagram-style story creation surface — a locked 9:16 wrapper around the media editor.

Category: MediaUpdated: 2026-08-19Created: 2026-05-31Author: ilinxa

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

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

Add -fixtures for dummy data:

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

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

demo.tsxtsx
"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.

Tags

story-composerstoriescameraimage-editorvideokonvasocialpublish

Dependencies

shadcn primitives: button
npm peer deps: lucide-react@^1.11.0
internal: media-editor