Browse the docs
ComponentsNo. 73 of 148
Story Frame
A 9:16 story preview that is the exported PNG itself, with Share (the phone's share sheet, Instagram included), Download and Copy link, and the safe area the apps draw over.
Installation
pnpm dlx shadcn@latest add @corsair-ui/story-frameNo setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.
- Also adds
- utilsbuttonstory-export
- npm
@radix-ui/react-slotclass-variance-authorityclsxlucide-reactsatoritailwind-merge
Examples
Share a story
On a phone, Share opens the share sheet with the PNG (Instagram Stories included) and copies the link for a link sticker; elsewhere, Download saves it.
Your own template
Any element with inline styles and flexbox. The safe area shows where Instagram draws its bars.
API
| Prop | Type | Default |
|---|---|---|
storyThe story element, such as a Story. Keep it stable (useMemo) so it is not drawn on every render. | ReactNode | — |
fontsFrom useStoryFonts([{ name, url, weight }]). The frame waits while it is null. | StoryFont[] | null | — |
altWhat the story shows, for screen readers. | string | — |
linkCopied when sharing and by its own button: Instagram takes no links from other apps, so the person pastes it into a link sticker. | string | — |
fileName / shareTitleName of the shared or downloaded file, and the title some share sheets show. | string | "story.png" |
defaultShowSafeAreaStarts with the bands the apps draw over shown. | boolean | false |
labelsEvery word on the buttons and in the announcements, for other languages. | Partial<StoryFrameLabels> | — |
onShare / onErrorAfter each share or download, and when drawing fails. | (result: "shared" | "cancelled" | "unsupported" | "downloaded") => void / (error) => void | — |
childrenMore buttons, after the built-in ones. | ReactNode | — |
Styling
Restyle it in your project: className on each part, the theme variables, and these attributes with arbitrary variants, e.g. className="[&_[data-slot=story-frame-actions]]:text-primary".
- The root has data-state="rendering" | "ready" | "error". The preview keeps its 9:16 box while the first image draws, so nothing moves.
- useStoryImage(story, { fonts }) is the drawing without the frame, for a layout of your own.
- The React Native StoryFrame lays the story out at full size, captures it with react-native-view-shot and shares it with expo-sharing.
- data-slot
story-framestory-frame-actionsstory-frame-previewstory-frame-safe-area
- State
data-statecopied · idle
Accessibility
- The preview is an <img> named by alt. A polite live region says when the story is drawn, shared or downloaded.
- Safe area is a toggle button with aria-pressed.
Compatibility
- Tailwind CSS
- 3.4 and 4, both checked in CI
- React
- 19
- Rendering
- Client component ("use client")
Source
1 file, installed as source you own
story-frame.tsxView on GitHub"use client";
import { CheckIcon, DownloadIcon, LinkIcon, ShareIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from "react";
import {
canShareStory,
downloadStory,
loadFont,
renderStorySvg,
shareStory,
STORY_SAFE_AREA,
STORY_SIZE,
svgToPng,
type StoryFont,
type StoryShareResult,
} from "@/registry/default/lib/story-export";
import { cn } from "@/registry/default/lib/utils";
import { Button } from "@/registry/default/ui/button";
/** A font to fetch for a story: where it is, and how the story names it. */
interface StoryFontSource extends Omit<StoryFont, "data"> {
url: string;
}
/**
* Fetches the fonts a story uses, once. `fonts` is null until all of them
* have loaded, which is what `StoryFrame` waits for.
*
* @example
* const { fonts } = useStoryFonts([
* { name: "Barlow", url: "/fonts/Barlow-Regular.ttf", weight: 400 },
* { name: "Barlow", url: "/fonts/Barlow-Bold.ttf", weight: 700 },
* ]);
*/
function useStoryFonts(sources: StoryFontSource[]) {
const [state, setState] = useState<{
key: string;
fonts: StoryFont[] | null;
error: Error | null;
}>({ key: "", fonts: null, error: null });
// A new array each render must not refetch: the sources themselves decide.
const key = JSON.stringify(sources);
useEffect(() => {
let active = true;
const wanted: StoryFontSource[] = JSON.parse(key);
Promise.all(
wanted.map(async ({ url, ...font }) => ({ ...font, data: await loadFont(url) }))
).then(
(fonts) => {
if (active) setState({ key, fonts, error: null });
},
(error: unknown) => {
if (active) setState({ key, fonts: null, error: toError(error) });
}
);
return () => {
active = false;
};
}, [key]);
return state.key === key
? { fonts: state.fonts, error: state.error }
: { fonts: null, error: null };
}
function toError(error: unknown) {
return error instanceof Error ? error : new Error(String(error));
}
type StoryImageStatus = "rendering" | "ready" | "error";
interface StoryImage {
status: StoryImageStatus;
/** The latest PNG, kept while a newer one renders. */
blob: Blob | null;
/** An object URL for `blob`, revoked when it is replaced or on unmount. */
url: string | null;
error: Error | null;
}
/**
* Renders a story to a PNG whenever it changes, a moment after the last
* change. Unchanged stories are not drawn again. For a layout of your own
* around the image; `StoryFrame` uses it.
*/
function useStoryImage(
story: ReactNode,
{
fonts,
width = STORY_SIZE.width,
height = STORY_SIZE.height,
delay = 120,
}: { fonts: StoryFont[] | null | undefined; width?: number; height?: number; delay?: number }
): StoryImage {
const [image, setImage] = useState<StoryImage>({
status: "rendering",
blob: null,
url: null,
error: null,
});
const drawn = useRef<string | null>(null);
const current = useRef<string | null>(null);
useEffect(() => {
if (!fonts) return;
let cancelled = false;
const timer = setTimeout(async () => {
try {
const svg = await renderStorySvg(story, { fonts, width, height });
if (cancelled || svg === drawn.current) return;
setImage((previous) => ({ ...previous, status: "rendering" }));
const blob = await svgToPng(svg, width, height);
if (cancelled) return;
const url = URL.createObjectURL(blob);
const previous = current.current;
drawn.current = svg;
current.current = url;
setImage({ status: "ready", blob, url, error: null });
if (previous) URL.revokeObjectURL(previous);
} catch (error) {
if (!cancelled)
setImage((previous) => ({ ...previous, status: "error", error: toError(error) }));
}
}, delay);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [story, fonts, width, height, delay]);
useEffect(
() => () => {
if (current.current) URL.revokeObjectURL(current.current);
},
[]
);
return image;
}
interface StoryFrameLabels {
share: string;
download: string;
copyLink: string;
copied: string;
safeArea: string;
/** Announced while the image is drawn. */
rendering: string;
/** Announced once the image is ready. */
ready: string;
error: string;
shared: string;
downloaded: string;
/** Under the buttons when there is a link: how to put it on the story. */
linkHint: string;
}
const DEFAULT_LABELS: StoryFrameLabels = {
share: "Share",
download: "Download",
copyLink: "Copy link",
copied: "Link copied",
safeArea: "Safe area",
rendering: "Drawing the story",
ready: "Story ready",
error: "The story could not be drawn.",
shared: "Story shared",
downloaded: "Story downloaded",
linkHint: "Sharing copies the link too: add a link sticker to the story and paste it.",
};
type StoryFrameResult = StoryShareResult | "downloaded";
interface StoryFrameProps extends Omit<ComponentProps<"div">, "children" | "onError"> {
/**
* The story: an element in the subset Satori supports (inline styles,
* flexbox), such as `AvailabilityStory`.
*/
story: ReactNode;
/** The fonts the story uses. Null or undefined while they load. */
fonts: StoryFont[] | null | undefined;
/** What the image shows, for screen readers. */
alt: string;
/** Put on the clipboard when sharing, and copied by its own button. */
link?: string;
/** Name of the shared or downloaded file. */
fileName?: string;
/** Title some share sheets show. */
shareTitle?: string;
width?: number;
height?: number;
/** Shows the bands apps draw over at first. */
defaultShowSafeArea?: boolean;
labels?: Partial<StoryFrameLabels>;
onShare?: (result: StoryFrameResult) => void;
onError?: (error: Error) => void;
/** More buttons, after the built-in ones. */
children?: ReactNode;
}
/**
* A story preview with what it takes to post it: on phones, Share opens the
* share sheet with the PNG (Instagram, WhatsApp, the photo library); where
* sharing files is not possible, Download saves it. With a `link`, sharing
* also puts it on the clipboard, ready for a link sticker, since Instagram
* does not take links from other apps.
*
* The preview is the exported image itself, drawn with Satori a moment after
* the story changes, so what you see is what gets posted. The image is ready
* before Share is pressed, as browsers only open the share sheet straight
* from a tap. "Safe area" shows the bands the apps draw over.
*
* `data-state` is `rendering`, `ready` or `error`; the parts are
* `story-frame-preview`, `story-frame-safe-area` and `story-frame-actions`.
*
* @example
* <StoryFrame
* story={<AvailabilityStory {...props} />}
* alt={availabilityStoryAlt(props)}
* fonts={fonts}
* link="https://example.com/book?via=story"
* fileName="free-today.png"
* />
*/
function StoryFrame({
story,
fonts,
alt,
link,
fileName = "story.png",
shareTitle,
width = STORY_SIZE.width,
height = STORY_SIZE.height,
defaultShowSafeArea = false,
labels: labelsProp,
onShare,
onError,
className,
children,
...props
}: StoryFrameProps) {
const labels = { ...DEFAULT_LABELS, ...labelsProp };
const image = useStoryImage(story, { fonts, width, height });
const [showSafeArea, setShowSafeArea] = useState(defaultShowSafeArea);
const [copied, setCopied] = useState(false);
const [announcement, setAnnouncement] = useState("");
const copiedTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const reported = useRef<Error | null>(null);
const file = useMemo(
() => (image.blob ? new File([image.blob], fileName, { type: "image/png" }) : null),
[image.blob, fileName]
);
const canShare = useMemo(() => (file ? canShareStory(file) : false), [file]);
const status: StoryImageStatus = fonts ? image.status : "rendering";
useEffect(() => {
if (image.error && reported.current !== image.error) {
reported.current = image.error;
onError?.(image.error);
}
}, [image.error, onError]);
useEffect(() => () => clearTimeout(copiedTimer.current), []);
const markCopied = () => {
setCopied(true);
clearTimeout(copiedTimer.current);
copiedTimer.current = setTimeout(() => setCopied(false), 1600);
};
const copyLink = async () => {
if (!link) return false;
try {
await navigator.clipboard.writeText(link);
markCopied();
return true;
} catch {
return false;
}
};
const download = () => {
if (!image.blob) return;
downloadStory(image.blob, fileName);
setAnnouncement(labels.downloaded);
onShare?.("downloaded");
};
const share = async () => {
if (!file) return;
// Started first and not awaited: the share sheet has to open within the tap.
const copying = link ? copyLink() : Promise.resolve(false);
try {
const result = await shareStory({ file, title: shareTitle });
await copying;
if (result === "unsupported") {
download();
return;
}
if (result === "shared") setAnnouncement(labels.shared);
onShare?.(result);
} catch (error) {
onError?.(toError(error));
}
};
const copy = async () => {
if (await copyLink()) setAnnouncement(labels.copied);
};
const safe = {
top: `${(STORY_SAFE_AREA.top / STORY_SIZE.height) * 100}%`,
bottom: `${(STORY_SAFE_AREA.bottom / STORY_SIZE.height) * 100}%`,
side: `${(STORY_SAFE_AREA.side / STORY_SIZE.width) * 100}%`,
};
return (
<div
data-slot="story-frame"
data-state={status}
className={cn("flex w-full max-w-xs flex-col items-center gap-4", className)}
{...props}
>
<div
data-slot="story-frame-preview"
aria-busy={status === "rendering" || undefined}
className="bg-muted relative w-full overflow-hidden rounded-2xl border"
style={{ aspectRatio: `${width} / ${height}` }}
>
{image.url ? (
<img
src={image.url}
alt={alt}
width={width}
height={height}
className={cn(
"block size-full object-cover",
status === "rendering" && "opacity-70 motion-safe:transition-opacity"
)}
/>
) : (
<div aria-hidden="true" className="bg-muted size-full motion-safe:animate-pulse" />
)}
{showSafeArea ? (
<div
data-slot="story-frame-safe-area"
aria-hidden="true"
className="pointer-events-none absolute inset-0"
>
<div
className="bg-destructive/25 border-destructive/60 absolute inset-x-0 top-0 border-b border-dashed"
style={{ height: safe.top }}
/>
<div
className="bg-destructive/25 border-destructive/60 absolute inset-x-0 bottom-0 border-t border-dashed"
style={{ height: safe.bottom }}
/>
<div
className="border-destructive/60 absolute border-x border-dashed"
style={{ top: safe.top, bottom: safe.bottom, left: safe.side, right: safe.side }}
/>
</div>
) : null}
{status === "error" ? (
<p
role="alert"
className="bg-background/90 text-destructive absolute inset-x-3 bottom-3 rounded-md px-3 py-2 text-sm"
>
{labels.error}
</p>
) : null}
</div>
<div
data-slot="story-frame-actions"
className="flex flex-wrap items-center justify-center gap-2"
>
{canShare ? (
<Button type="button" onClick={share} disabled={!file}>
<ShareIcon aria-hidden="true" />
{labels.share}
</Button>
) : null}
<Button
type="button"
// A Button variant, not a class.
// tailwind-compat-ignore-next-line
variant={canShare ? "outline" : "default"}
onClick={download}
disabled={!image.blob}
>
<DownloadIcon aria-hidden="true" />
{labels.download}
</Button>
{link ? (
<Button
type="button"
variant="outline"
onClick={copy}
data-state={copied ? "copied" : "idle"}
>
{copied ? <CheckIcon aria-hidden="true" /> : <LinkIcon aria-hidden="true" />}
{copied ? labels.copied : labels.copyLink}
</Button>
) : null}
<Button
type="button"
variant="ghost"
aria-pressed={showSafeArea}
onClick={() => setShowSafeArea((shown) => !shown)}
>
{labels.safeArea}
</Button>
{children}
</div>
{link && canShare ? (
<p className="text-muted-foreground text-center text-sm text-pretty">{labels.linkHint}</p>
) : null}
<span role="status" aria-live="polite" className="sr-only">
{announcement ||
(status === "ready" ? labels.ready : status === "rendering" ? labels.rendering : "")}
</span>
</div>
);
}
export {
StoryFrame,
useStoryFonts,
useStoryImage,
type StoryFontSource,
type StoryFrameLabels,
type StoryFrameProps,
type StoryFrameResult,
type StoryImage,
type StoryImageStatus,
};