Browse the docs
ComponentsNo. 72 of 148
Story
A 1080 × 1920 story put together from parts (header, eyebrow, title, grid of tiles, list of rows, highlight, image, call to action), laid out inside the safe area, with themes, patterns and a background photo. Drawn by Satori: render it with story-frame or renderStoryPng().
Installation
pnpm dlx shadcn@latest add @corsair-ui/storyNo 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
- story-export
- npm
satori
Examples
Five stories, one set of parts
Free slots, a menu, a last-minute spot, an event and the week, each a Story with a theme, a pattern and the parts it needs.
Themes
midnight, paper, ocean, sunset and mono, or colors of your own.
Long lists and other languages
Thirty times in Portuguese: the grid shows what fits and its last tile counts the rest.
API
| Prop | Type | Default |
|---|---|---|
theme / colorsA preset or colours of your own, and overrides on top: { accent: "#e11d48" }. Exported as STORY_THEMES. | "midnight" | "paper" | "ocean" | "sunset" | "mono" | StoryTheme / Partial<StoryTheme> | "midnight" |
decorationA light or a pattern behind the content, in the theme's colours. | "none" | "glow" | "dots" | "grid" | "none" |
image / imageDimA photo behind everything (a URL that allows CORS, or a data URL), dimmed and faded into the background. | string / number | — / 0.55 |
fontFamily / radius / justify / gapThe family to draw with, the corners of tiles and cards, and how parts spread. | string / number / "start" | "center" | "end" | "between" / number | first font / 36 / "start" / 48 |
PartsPut together inside Story. Grids and rows take max and more, so long lists end in a +N instead of overflowing. | StoryHeader, StoryBrand, StoryEyebrow, StoryTitle, StoryText, StoryBody, StoryGrid, StoryTile, StoryList, StoryRow, StoryChip, StoryHighlight, StoryImage, StoryFooter, StoryCta, StoryLinkHint, StoryDivider | — |
Styling
Restyle it in your project: className on each part, the theme variables, and these attributes with arbitrary variants.
- Content sits inside the safe area, clear of the profile bar and the reply field. StoryBody takes the height left, so the footer stays at the bottom.
- Parts take style, and your own elements can read the theme as CSS variables: var(--story-accent), var(--story-surface), var(--story-muted).
- Like everything Satori draws, it is inline styles and flexbox, no classes. Render it with story-frame or renderStoryPng() in the browser.
- On a server it renders with next/og: return new ImageResponse(<Story …>…</Story>, { width: 1080, height: 1920, fonts: [{ name: "Barlow", data, weight: 700 }] }) from a route, then link or download the PNG.
Accessibility
- storyAlt(story) reads the story in order for the image's alt. Tiles and chips with an alt are read by it ("19h, booked").
Compatibility
- Tailwind CSS
- 3.4 and 4, both checked in CI
- React
- 19
- Rendering
- Works in server components
Source
1 file, installed as source you own
story.tsxView on GitHub// Satori draws stories from inline styles; there are no Tailwind classes here to check.
// tailwind-compat-ignore-file
import { Children, cloneElement, isValidElement, type CSSProperties, type ReactNode } from "react";
import { STORY_SAFE_AREA, STORY_SIZE } from "@/registry/default/lib/story-export";
/**
* The colours a story is drawn with. Parts read them as CSS variables
* (`var(--story-accent)`), so your own elements inside a story can too.
*/
interface StoryTheme {
background: string;
/** Drawn over `background`: a gradient, for example. */
backgroundImage?: string;
foreground: string;
muted: string;
/** Solid tiles and chips, the call to action, eyebrows and glows. */
accent: string;
/** Text on the accent. */
accentForeground: string;
/** Soft tiles and chips. */
surface: string;
/** Outlines and dividers. */
border: string;
}
const STORY_THEMES = {
midnight: {
background: "#09090b",
foreground: "#fafafa",
muted: "rgba(250, 250, 250, 0.6)",
accent: "#c6f432",
accentForeground: "#09090b",
surface: "rgba(250, 250, 250, 0.08)",
border: "rgba(250, 250, 250, 0.16)",
},
paper: {
background: "#f3efe6",
foreground: "#141414",
muted: "rgba(20, 20, 20, 0.58)",
accent: "#ff5a1f",
accentForeground: "#ffffff",
surface: "rgba(20, 20, 20, 0.06)",
border: "rgba(20, 20, 20, 0.14)",
},
ocean: {
background: "#041029",
backgroundImage: "linear-gradient(165deg, #0c2a6b 0%, #041029 70%)",
foreground: "#eef5ff",
muted: "rgba(238, 245, 255, 0.64)",
accent: "#5ee7ff",
accentForeground: "#041029",
surface: "rgba(238, 245, 255, 0.1)",
border: "rgba(238, 245, 255, 0.2)",
},
sunset: {
background: "#ff3d77",
backgroundImage: "linear-gradient(165deg, #ff8a3d 0%, #ff3d77 50%, #7b2cff 100%)",
foreground: "#ffffff",
muted: "rgba(255, 255, 255, 0.8)",
accent: "#ffffff",
accentForeground: "#2b0b3f",
surface: "rgba(255, 255, 255, 0.18)",
border: "rgba(255, 255, 255, 0.4)",
},
mono: {
background: "#ffffff",
foreground: "#0a0a0a",
muted: "rgba(10, 10, 10, 0.55)",
accent: "#0a0a0a",
accentForeground: "#ffffff",
surface: "rgba(10, 10, 10, 0.06)",
border: "rgba(10, 10, 10, 0.16)",
},
} satisfies Record<string, StoryTheme>;
type StoryThemeName = keyof typeof STORY_THEMES;
type StoryDecoration = "none" | "glow" | "dots" | "grid";
/** Side padding of the content: the safe area's, plus a little air. */
const SIDE = STORY_SAFE_AREA.side + 8;
/** Width of the content between the side paddings. */
const STORY_CONTENT_WIDTH = STORY_SIZE.width - SIDE * 2;
const v = {
background: "var(--story-background)",
foreground: "var(--story-foreground)",
muted: "var(--story-muted)",
accent: "var(--story-accent)",
accentForeground: "var(--story-accent-foreground)",
surface: "var(--story-surface)",
border: "var(--story-border)",
radius: "var(--story-radius)",
} as const;
const nowrap: CSSProperties = {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
};
/** A hex colour at an opacity; other colours come back as they are. */
function alpha(color: string, amount: number) {
const hex = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(color.trim())?.[1];
if (!hex) return color;
const full = hex.length === 3 ? [...hex].map((digit) => digit + digit).join("") : hex;
const [r, g, b] = [0, 2, 4].map((index) => parseInt(full.slice(index, index + 2), 16));
return `rgba(${r}, ${g}, ${b}, ${amount})`;
}
function decorationStyle(decoration: StoryDecoration, theme: StoryTheme): CSSProperties | null {
switch (decoration) {
case "glow":
return {
backgroundImage: `radial-gradient(circle at 92% 6%, ${alpha(theme.accent, 0.42)} 0%, rgba(0, 0, 0, 0) 48%), radial-gradient(circle at 0% 100%, ${alpha(theme.accent, 0.18)} 0%, rgba(0, 0, 0, 0) 42%)`,
};
case "dots":
return {
backgroundImage: `radial-gradient(circle, ${theme.border} 3px, rgba(0, 0, 0, 0) 3.5px)`,
backgroundSize: "54px 54px",
};
case "grid":
return {
backgroundImage: `linear-gradient(${theme.border} 2px, rgba(0, 0, 0, 0) 2px), linear-gradient(90deg, ${theme.border} 2px, rgba(0, 0, 0, 0) 2px)`,
backgroundSize: "108px 108px",
};
default:
return null;
}
}
interface StoryProps {
/** A preset, or your own colours. Defaults to `midnight`. */
theme?: StoryThemeName | StoryTheme;
/** Overrides some colours of the theme: `{ accent: "#e11d48" }`. */
colors?: Partial<StoryTheme>;
/** A photo behind everything (a URL that allows CORS, or a data URL), darkened by `imageDim`. */
image?: string;
/** From 0 to 1: how much of the background colour covers the photo. */
imageDim?: number;
/** A pattern or light behind the content, in the theme's colours. */
decoration?: StoryDecoration;
/** A family from the fonts given to the renderer. Defaults to the first one. */
fontFamily?: string;
/** Corner radius of tiles, images and cards, in px. */
radius?: number;
/** How the parts spread over the safe area when there is room left. */
justify?: "start" | "center" | "end" | "between";
/** Space between the parts, in px. */
gap?: number;
style?: CSSProperties;
children?: ReactNode;
}
const JUSTIFY = {
start: "flex-start",
center: "center",
end: "flex-end",
between: "space-between",
} as const;
/**
* A 1080 × 1920 story, for Instagram, WhatsApp or TikTok, put together from
* parts: `StoryHeader`, `StoryEyebrow`, `StoryTitle`, `StoryText`,
* `StoryBody` with a `StoryGrid` of `StoryTile`s or a `StoryList` of
* `StoryRow`s, `StoryHighlight`, and a `StoryFooter` with a `StoryCta`.
* Content is laid out inside the stories' safe area, clear of the bars the
* apps draw. Themes, colours, a photo, a pattern, the font and the radius
* are all props; every part also takes `style`, and your own elements can
* use the theme's CSS variables (`var(--story-accent)`).
*
* It is an image template: render it with `StoryFrame` or `renderStoryPng`
* in the browser, or with `ImageResponse` from `next/og` on a server. Like
* everything Satori draws, it takes inline styles and flexbox, not classes.
*
* @example
* <Story theme="midnight" decoration="glow">
* <StoryHeader><StoryBrand name="North Court" /></StoryHeader>
* <StoryTitle>Free today</StoryTitle>
* <StoryBody>
* <StoryGrid max={9}>
* <StoryTile label="18h" />
* <StoryTile label="19h" variant="muted" strike />
* </StoryGrid>
* </StoryBody>
* <StoryFooter><StoryCta>Book through the link</StoryCta></StoryFooter>
* </Story>
*/
function Story({
theme = "midnight",
colors,
image,
imageDim = 0.55,
decoration = "none",
fontFamily,
radius = 36,
justify = "start",
gap = 48,
style,
children,
}: StoryProps) {
const palette: StoryTheme = {
...(typeof theme === "string" ? STORY_THEMES[theme] : theme),
...colors,
};
const { width, height } = STORY_SIZE;
const layer: CSSProperties = {
display: "flex",
position: "absolute",
top: 0,
left: 0,
width,
height,
};
const pattern = decorationStyle(decoration, palette);
return (
<div
style={
{
"--story-background": palette.background,
"--story-foreground": palette.foreground,
"--story-muted": palette.muted,
"--story-accent": palette.accent,
"--story-accent-foreground": palette.accentForeground,
"--story-surface": palette.surface,
"--story-border": palette.border,
"--story-radius": `${radius}px`,
display: "flex",
position: "relative",
width,
height,
overflow: "hidden",
backgroundColor: palette.background,
...(palette.backgroundImage ? { backgroundImage: palette.backgroundImage } : {}),
color: palette.foreground,
// Left out when unset: the Satori in next/og fails on undefined style values.
...(fontFamily ? { fontFamily } : {}),
...style,
} as CSSProperties
}
>
{image ? (
<>
<img
src={image}
alt=""
width={width}
height={height}
style={{ ...layer, objectFit: "cover" }}
/>
<div style={{ ...layer, backgroundColor: palette.background, opacity: imageDim }} />
{/* Fades the photo into the background towards the bottom, where the text sits. */}
<div
style={{
...layer,
backgroundImage: `linear-gradient(180deg, rgba(0, 0, 0, 0) 30%, ${palette.background} 92%)`,
}}
/>
</>
) : null}
{pattern ? <div style={{ ...layer, ...pattern }} /> : null}
<div
style={{
display: "flex",
flexDirection: "column",
// Positioned, so it paints above the photo and the pattern, which are.
position: "relative",
flexShrink: 0,
width,
height,
boxSizing: "border-box",
padding: `${STORY_SAFE_AREA.top}px ${SIDE}px ${STORY_SAFE_AREA.bottom}px`,
justifyContent: JUSTIFY[justify],
gap,
}}
>
{children}
</div>
</div>
);
}
interface StoryPartProps {
style?: CSSProperties;
children?: ReactNode;
}
/** A row at the top: a `StoryBrand` at the start and, say, a date `StoryChip` at the end. */
function StoryHeader({ style, children }: StoryPartProps) {
return (
<div
style={{
display: "flex",
flexShrink: 0,
alignItems: "center",
justifyContent: "space-between",
gap: 24,
minHeight: 96,
...style,
}}
>
{children}
</div>
);
}
interface StoryBrandProps {
name: string;
/** A URL that allows CORS, or a data URL. */
logo?: string;
style?: CSSProperties;
}
/** The logo and name of whoever posts the story. */
function StoryBrand({ name, logo, style }: StoryBrandProps) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 22, minWidth: 0, ...style }}>
{logo ? (
<img
src={logo}
alt=""
width={88}
height={88}
style={{ width: 88, height: 88, borderRadius: 24, objectFit: "cover", flexShrink: 0 }}
/>
) : null}
<div style={{ display: "flex", fontSize: 42, fontWeight: 700, maxWidth: 640, ...nowrap }}>
{name}
</div>
</div>
);
}
/** A short label above the title, in the accent: "Free slots", "New", "This Friday". */
function StoryEyebrow({ style, children }: StoryPartProps) {
return (
<div
style={{
display: "flex",
flexShrink: 0,
alignItems: "center",
gap: 18,
color: v.accent,
fontSize: 34,
fontWeight: 700,
letterSpacing: 5,
textTransform: "uppercase",
...style,
}}
>
<div
style={{
display: "flex",
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: v.accent,
flexShrink: 0,
}}
/>
<div style={{ display: "flex", maxWidth: STORY_CONTENT_WIDTH - 40, ...nowrap }}>
{children}
</div>
</div>
);
}
function textLength(node: ReactNode): number {
if (typeof node === "string" || typeof node === "number") return String(node).length;
if (Array.isArray(node)) return node.reduce((sum: number, child) => sum + textLength(child), 0);
if (isValidElement<{ children?: ReactNode }>(node)) return textLength(node.props.children);
return 0;
}
interface StoryTitleProps extends StoryPartProps {
/** Font size in px. By default it gets smaller as the title gets longer, to stay within `lines`. */
size?: number;
/** Lines before it is cut with an ellipsis. */
lines?: number;
}
/** The headline, large and tight. */
function StoryTitle({ size, lines = 3, style, children }: StoryTitleProps) {
const length = textLength(children);
const fontSize =
size ?? (length <= 12 ? 150 : length <= 20 ? 124 : length <= 32 ? 104 : length <= 48 ? 88 : 76);
return (
<div
style={{
display: "flex",
flexShrink: 0,
fontSize,
fontWeight: 700,
lineHeight: 0.98,
letterSpacing: -Math.round(fontSize * 0.03),
lineClamp: lines,
...style,
}}
>
{children}
</div>
);
}
interface StoryTextProps extends StoryPartProps {
size?: number;
lines?: number;
}
/** A line or two in the muted colour: the day, the place, the details. */
function StoryText({ size = 46, lines = 3, style, children }: StoryTextProps) {
return (
<div
style={{
display: "flex",
flexShrink: 0,
fontSize: size,
lineHeight: 1.3,
color: v.muted,
lineClamp: lines,
...style,
}}
>
{children}
</div>
);
}
interface StoryBodyProps extends StoryPartProps {
justify?: "start" | "center" | "end";
gap?: number;
}
/**
* Takes the height the other parts leave, so the footer stays at the
* bottom of the safe area. What does not fit is clipped: give grids and
* lists a `max`.
*/
function StoryBody({ justify = "start", gap = 28, style, children }: StoryBodyProps) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
minHeight: 0,
overflow: "hidden",
justifyContent: JUSTIFY[justify],
gap,
...style,
}}
>
{children}
</div>
);
}
type StoryVariant = "solid" | "soft" | "outline" | "muted";
function look(variant: StoryVariant | "dashed") {
switch (variant) {
case "soft":
return { backgroundColor: v.surface, borderColor: "rgba(0, 0, 0, 0)", color: v.foreground };
case "outline":
return { backgroundColor: "rgba(0, 0, 0, 0)", borderColor: v.accent, color: v.foreground };
case "muted":
return { backgroundColor: v.surface, borderColor: "rgba(0, 0, 0, 0)", color: v.muted };
case "dashed":
return { backgroundColor: "rgba(0, 0, 0, 0)", borderColor: v.border, color: v.foreground };
default:
return { backgroundColor: v.accent, borderColor: v.accent, color: v.accentForeground };
}
}
/** Keeps the first `max` items; the last place goes to a "+N" for the rest. */
function limit(children: ReactNode, max: number | undefined) {
const items = Children.toArray(children);
if (!max || items.length <= max) return { items, hidden: 0 };
const shown = items.slice(0, Math.max(0, max - 1));
return { items: shown, hidden: items.length - shown.length };
}
interface StoryTileProps {
label: ReactNode;
/** A smaller line under the label: a price, "2 left". */
note?: ReactNode;
/** `solid` in the accent, `soft` on the surface, `outline`, or `muted` for what is gone. */
variant?: StoryVariant;
/** Strikes the label through, for what is taken or sold out. */
strike?: boolean;
/** What screen readers hear instead of the text, e.g. "19h, taken". Used by `storyAlt`. */
alt?: string;
style?: CSSProperties;
}
function labelSize(length: number) {
if (length <= 4) return 64;
if (length <= 6) return 56;
if (length <= 9) return 46;
return 38;
}
/** One cell of a `StoryGrid`: a time, a size, a seat. */
function StoryTile({ label, note, variant = "solid", strike, style }: StoryTileProps) {
return (
<StoryTileBox variant={variant} style={style}>
<div
style={{
display: "flex",
fontSize: labelSize(textLength(label)),
fontWeight: 700,
lineHeight: 1,
textDecoration: strike ? "line-through" : "none",
maxWidth: "100%",
...nowrap,
}}
>
{label}
</div>
{note ? (
<div
style={{
display: "flex",
marginTop: 12,
fontSize: 30,
lineHeight: 1,
opacity: 0.75,
maxWidth: "100%",
...nowrap,
}}
>
{note}
</div>
) : null}
</StoryTileBox>
);
}
function StoryTileBox({
variant,
style,
children,
}: {
variant: StoryVariant | "dashed";
style?: CSSProperties;
children: ReactNode;
}) {
const colors = look(variant);
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
flexGrow: 1,
minHeight: 140,
padding: "28px 20px",
boxSizing: "border-box",
borderRadius: v.radius,
borderWidth: 4,
borderStyle: variant === "dashed" ? "dashed" : "solid",
...colors,
...style,
}}
>
{children}
</div>
);
}
interface StoryGridProps extends StoryPartProps {
columns?: number;
gap?: number;
/** The most cells shown; the last one becomes `more` for the rest. */
max?: number;
/** Label of the cell that stands for what did not fit. */
more?: (hidden: number) => string;
/** Width the grid fills, in px. The content width by default. */
width?: number;
}
/** Tiles in rows of `columns`, ending in "+N" when there are more than `max`. */
function StoryGrid({
columns = 3,
gap = 24,
max,
more = (hidden) => `+${hidden}`,
width = STORY_CONTENT_WIDTH,
style,
children,
}: StoryGridProps) {
const { items, hidden } = limit(children, max);
const cell = Math.floor((width - gap * (columns - 1)) / columns);
const moreLabel = hidden > 0 ? more(hidden) : "";
return (
<div style={{ display: "flex", flexWrap: "wrap", gap, width, flexShrink: 0, ...style }}>
{items.map((item, index) => (
<div key={index} style={{ display: "flex", width: cell }}>
{item}
</div>
))}
{hidden > 0 ? (
<div style={{ display: "flex", width: cell }}>
<StoryTileBox variant="dashed">
<div
style={{
display: "flex",
fontSize: labelSize(moreLabel.length) - 8,
fontWeight: 700,
maxWidth: "100%",
...nowrap,
}}
>
{moreLabel}
</div>
</StoryTileBox>
</div>
) : null}
</div>
);
}
interface StoryChipProps {
children: ReactNode;
variant?: StoryVariant;
strike?: boolean;
alt?: string;
style?: CSSProperties;
}
/** A pill: a time in a row, a date in the header, a price, "New". */
function StoryChip({ variant = "soft", strike, style, children }: StoryChipProps) {
return (
<div
style={{
display: "flex",
flexShrink: 0,
alignItems: "center",
height: 76,
padding: "0 30px",
boxSizing: "border-box",
borderRadius: 999,
borderWidth: 3,
borderStyle: "solid",
fontSize: 36,
fontWeight: 700,
textDecoration: strike ? "line-through" : "none",
...look(variant),
...nowrap,
...style,
}}
>
{children}
</div>
);
}
interface StoryListProps extends StoryPartProps {
/** Lines between rows. */
divider?: boolean;
}
/** Rows one under the other: a day per row, a menu, a line-up. */
function StoryList({ divider = true, style, children }: StoryListProps) {
const rows = Children.toArray(children);
return (
<div style={{ display: "flex", flexDirection: "column", flexShrink: 0, ...style }}>
{rows.map((row, index) =>
isValidElement<StoryRowProps>(row)
? cloneElement(row, { divider: divider && index < rows.length - 1 })
: row
)}
</div>
);
}
interface StoryRowProps {
/** At the start, in bold: "Mon", "Haircut". */
label: ReactNode;
/** Under the label, muted: "30 min". */
detail?: ReactNode;
/** At the end: a price, a time. */
value?: ReactNode;
/** Chips between the label and the value. */
children?: ReactNode;
/** The most chips shown; the last place says how many more. */
max?: number;
more?: (hidden: number) => string;
/** Width of the label column, in px, to line rows up. */
labelWidth?: number;
/** Set by StoryList. */
divider?: boolean;
style?: CSSProperties;
}
/** One row of a `StoryList`. */
function StoryRow({
label,
detail,
value,
children,
max,
more = (hidden) => `+${hidden}`,
labelWidth,
divider,
style,
}: StoryRowProps) {
const { items, hidden } = limit(children, max);
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 24,
padding: "22px 0",
borderBottom: divider ? `2px solid ${v.border}` : "2px solid rgba(0, 0, 0, 0)",
...style,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
flexShrink: 0,
...(labelWidth ? { width: labelWidth } : { maxWidth: 520 }),
}}
>
<div style={{ display: "flex", fontSize: 46, fontWeight: 700, lineHeight: 1.1, ...nowrap }}>
{label}
</div>
{detail ? (
<div
style={{
display: "flex",
marginTop: 6,
fontSize: 32,
color: v.muted,
...nowrap,
}}
>
{detail}
</div>
) : null}
</div>
<div
style={{
display: "flex",
flexGrow: 1,
minWidth: 0,
gap: 14,
overflow: "hidden",
justifyContent: value ? "flex-start" : "flex-end",
}}
>
{items}
{hidden > 0 ? (
<StoryChip variant="outline" style={{ borderStyle: "dashed", borderColor: v.border }}>
{more(hidden)}
</StoryChip>
) : null}
</div>
{value ? (
<div style={{ display: "flex", flexShrink: 0, fontSize: 46, fontWeight: 700 }}>{value}</div>
) : null}
</div>
);
}
interface StoryHighlightProps extends StoryPartProps {
/** Above, muted: "Tonight at". */
label?: ReactNode;
/** Below, muted: "Only one left". */
caption?: ReactNode;
/** `accent` draws the big text in the accent colour. */
tone?: "default" | "accent";
/** Font size in px; by default it fits the length. */
size?: number;
}
/** One thing in very large type: a time, a date, a price, a score. */
function StoryHighlight({
label,
caption,
tone = "default",
size,
style,
children,
}: StoryHighlightProps) {
const length = textLength(children);
const fontSize = size ?? (length <= 3 ? 320 : length <= 5 ? 260 : length <= 8 ? 190 : 140);
return (
<div style={{ display: "flex", flexDirection: "column", flexShrink: 0, ...style }}>
{label ? (
<div style={{ display: "flex", fontSize: 44, color: v.muted, marginBottom: 8 }}>
{label}
</div>
) : null}
<div
style={{
display: "flex",
fontSize,
fontWeight: 700,
lineHeight: 0.9,
letterSpacing: -Math.round(fontSize * 0.045),
color: tone === "accent" ? v.accent : v.foreground,
maxWidth: STORY_CONTENT_WIDTH,
...nowrap,
}}
>
{children}
</div>
{caption ? (
<div
style={{
display: "flex",
marginTop: 28,
fontSize: 48,
lineHeight: 1.25,
color: v.muted,
lineClamp: 2,
}}
>
{caption}
</div>
) : null}
</div>
);
}
interface StoryImageProps {
/** A URL that allows CORS, or a data URL. */
src: string;
height?: number;
width?: number;
style?: CSSProperties;
}
/** A photo inside the content, with the theme's corners. */
function StoryImage({ src, height = 560, width = STORY_CONTENT_WIDTH, style }: StoryImageProps) {
return (
<img
src={src}
alt=""
width={width}
height={height}
style={{ width, height, flexShrink: 0, objectFit: "cover", borderRadius: v.radius, ...style }}
/>
);
}
/** The bottom of the story: a `StoryCta` and a `StoryLinkHint`. */
function StoryFooter({ style, children }: StoryPartProps) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
flexShrink: 0,
gap: 24,
marginTop: "auto",
...style,
}}
>
{children}
</div>
);
}
interface StoryCtaProps extends StoryPartProps {
variant?: "solid" | "outline";
/** An arrow after the text. */
arrow?: boolean;
}
/** The call to action: "Book through the link". */
function StoryCta({ variant = "solid", arrow = true, style, children }: StoryCtaProps) {
const solid = variant === "solid";
const color = solid ? v.accentForeground : v.foreground;
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 20,
height: 112,
padding: "0 52px",
boxSizing: "border-box",
borderRadius: 999,
borderWidth: 4,
borderStyle: "solid",
borderColor: solid ? v.accent : v.foreground,
backgroundColor: solid ? v.accent : "rgba(0, 0, 0, 0)",
color,
fontSize: 46,
fontWeight: 700,
maxWidth: STORY_CONTENT_WIDTH,
...style,
}}
>
<div style={{ display: "flex", ...nowrap }}>{children}</div>
{arrow ? <Arrow color={solid ? "currentColor" : "currentColor"} /> : null}
</div>
);
}
function Arrow({ color }: { color: string }) {
return (
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
<path
d="M5 12h14m0 0-6-6m6 6-6 6"
stroke={color}
strokeWidth="2.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/** A small line pointing at where the link sticker goes: "Tap the link". */
function StoryLinkHint({ style, children }: StoryPartProps) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 14,
fontSize: 36,
color: v.muted,
...style,
}}
>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
<path
d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div style={{ display: "flex", ...nowrap }}>{children}</div>
</div>
);
}
/** A line across the content, in the border colour. */
function StoryDivider({ style }: { style?: CSSProperties }) {
return (
<div
style={{ display: "flex", flexShrink: 0, height: 2, backgroundColor: v.border, ...style }}
/>
);
}
type AnyProps = Record<string, unknown> & { children?: ReactNode; alt?: unknown };
/**
* The text of a story, in reading order, for the image's `alt`. Parts with
* an `alt` prop (tiles, chips) are read by it instead of their text, so a
* struck-through "19h" can say "19h, taken".
*
* @example
* <StoryFrame story={story} alt={storyAlt(story)} … />
*/
function storyAlt(story: ReactNode): string {
const runs: string[] = [];
const walk = (node: ReactNode) => {
if (node === null || node === undefined || typeof node === "boolean") return;
if (typeof node === "string" || typeof node === "number") {
const text = String(node).trim();
if (text) runs.push(text);
return;
}
if (Array.isArray(node)) {
node.forEach(walk);
return;
}
if (!isValidElement<AnyProps>(node)) return;
const { props, type } = node;
if (typeof props.alt === "string" && type !== "img") {
if (props.alt.trim()) runs.push(props.alt.trim());
return;
}
// Parts are plain functions without hooks, so they can be expanded here as Satori does.
if (typeof type === "function") {
walk((type as (props: AnyProps) => ReactNode)(props));
return;
}
walk(props.children);
};
walk(story);
return runs.map((run) => (/[.!?…:]$/.test(run) ? run : `${run}.`)).join(" ");
}
export {
Story,
STORY_CONTENT_WIDTH,
STORY_THEMES,
storyAlt,
StoryBody,
StoryBrand,
StoryChip,
StoryCta,
StoryDivider,
StoryEyebrow,
StoryFooter,
StoryGrid,
StoryHeader,
StoryHighlight,
StoryImage,
StoryLinkHint,
StoryList,
StoryRow,
StoryText,
StoryTile,
StoryTitle,
type StoryDecoration,
type StoryProps,
type StoryTheme,
type StoryThemeName,
type StoryVariant,
};