Social post mockup
Social posts as pictures for your page, without the mark of any network. Picture posts, text posts, stories, carousels, video reels and a profile grid. Like by double tap, save, follow, swipe the slides and tap through stories.
One of 3 components in Campaign mockups
Variants
Details
What Social post mockup brings along, read from the registry and the source.
- Category
- Campaign mockups
- Area
- Visuals
- Variants
- 6
- Version
- 1.0.1 · since 1.1.0
- File
- components/ui/social-post-mockup.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- English and German, 21 texts of its own
- Studio
- All 6 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SocialPostProps interface from components/ui/social-post-mockup.tsx, with the comments from the code.
variantoptionalSocialPostVariantfeed-posttext-poststorycarouselvideo-reelprofile-grid
authorrequiredSocialAuthorcaptionoptionalstringText of the post, #tags and @names are tinted
mediaoptionalReact.ReactNode[]Pictures: one for feed-post, several for carousel, story, profile-grid
overlaysoptionalstring[]Text over each slide or story frame, by index
likesoptionalnumbercommentsoptionalnumbersharesoptionalnumbertimeoptionalstringe.g. "2 h" or "Oct 14"
commentListoptional{ name: string; text: string }[]feed-post: a few comments under the caption
cardoptional{ title: string; domain: string; media?: React.ReactNode; href?: string }text-post: a linked card under the text
soundoptionalstringvideo-reel: the sound line
biooptionalstringprofile-grid
statsoptional{ label: string; value: number }[]durationoptionalnumberSeconds per story frame
defaultLikedoptionalbooleanStart liked (Studio preview)
onActionoptional(action: SocialAction, text?: string) => voidComment, share, more, message and story reply. Without it these show as quiet icons, not as buttons.
Built-in texts
Social post mockup writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 21 texts
| Key | English | German |
|---|---|---|
| like | English Like | German Gefällt mir |
| unlike | English Remove like | German Gefällt mir entfernen |
| comment | English Comment | German Kommentieren |
| share | English Share | German Teilen |
| save | English Save | German Speichern |
| saved | English Saved | German Gespeichert |
| follow | English Follow | German Folgen |
| following | English Following | German Du folgst |
| more | English More | German Mehr |
| viewAll | English Show {n} comments | German {n} Kommentare zeigen |
| reply | English Reply to {name} | German Antwort an {name} |
| play | English Play | German Abspielen |
| pause | English Pause | German Anhalten |
| prev | English Previous | German Zurück |
| next | English Next | German Weiter |
| slide | English Slide | German Bild |
| posts | English Posts | German Beiträge |
| repost | English Repost | German Weiterteilen |
| verified | English Verified | German Bestätigt |
| send | English Send | German Senden |
| message | English Message | German Nachricht |
Accessibility
Read from the source of the component.
- Social post mockup handles the keys Arrow left, Arrow right and Space itself.
- Sets aria-label, aria-pressed and aria-roledescription, so screen readers announce purpose and state.
- Uses the roles group and img.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 606 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
/*
* Social posts as pictures for a page: how a campaign, a post or a story will
* look, without the mark of any network. Six formats:
*
* feed-post picture post with author, actions, likes, caption and comments
* text-post a short text post with a quoted card and reply, repost and like counts
* story a vertical story with segment bars that fill, tap left and right to move
* carousel a square post with slides you can swipe or drag, dots and a counter
* video-reel a vertical video post with play and pause, progress and an action bar
* profile-grid a profile head with numbers and a grid of posts with counts on hover
*
* The actions work: like (also by double tap on the picture) with a small
* burst, save, follow. Counts follow the locale in short form (1.2K, 1,2 Tsd.).
* Pictures are slots, the demos use drawn motifs.
*/
export type SocialPostVariant = 'feed-post' | 'text-post' | 'story' | 'carousel' | 'video-reel' | 'profile-grid';
export type SocialAction = 'comment' | 'share' | 'more' | 'message' | 'reply';
export interface SocialAuthor {
name: string;
handle: string;
avatar?: React.ReactNode;
verified?: boolean;
}
export interface SocialPostProps {
variant?: SocialPostVariant;
author: SocialAuthor;
/** Text of the post, #tags and @names are tinted */
caption?: string;
/** Pictures: one for feed-post, several for carousel, story, profile-grid */
media?: React.ReactNode[];
/** Text over each slide or story frame, by index */
overlays?: string[];
likes?: number;
comments?: number;
shares?: number;
/** e.g. "2 h" or "Oct 14" */
time?: string;
/** feed-post: a few comments under the caption */
commentList?: { name: string; text: string }[];
/** text-post: a linked card under the text */
card?: { title: string; domain: string; media?: React.ReactNode; href?: string };
/** video-reel: the sound line */
sound?: string;
/** profile-grid */
bio?: string;
stats?: { label: string; value: number }[];
/** Seconds per story frame */
duration?: number;
/** Start liked (Studio preview) */
defaultLiked?: boolean;
/** Comment, share, more, message and story reply. Without it these show as quiet icons, not as buttons. */
onAction?: (action: SocialAction, text?: string) => void;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: { like: 'Like', unlike: 'Remove like', comment: 'Comment', share: 'Share', save: 'Save', saved: 'Saved', follow: 'Follow', following: 'Following', more: 'More', viewAll: 'Show {n} comments', reply: 'Reply to {name}', play: 'Play', pause: 'Pause', prev: 'Previous', next: 'Next', slide: 'Slide', posts: 'Posts', repost: 'Repost', verified: 'Verified', send: 'Send', message: 'Message' },
de: { like: 'Gefällt mir', unlike: 'Gefällt mir entfernen', comment: 'Kommentieren', share: 'Teilen', save: 'Speichern', saved: 'Gespeichert', follow: 'Folgen', following: 'Du folgst', more: 'Mehr', viewAll: '{n} Kommentare zeigen', reply: 'Antwort an {name}', play: 'Abspielen', pause: 'Anhalten', prev: 'Zurück', next: 'Weiter', slide: 'Bild', posts: 'Beiträge', repost: 'Weiterteilen', verified: 'Bestätigt', send: 'Senden', message: 'Nachricht' },
};
type Texts = (typeof TEXTS)['en'];
const STYLES = `
@keyframes ksp-pop{0%{transform:scale(1)}35%{transform:scale(1.32)}100%{transform:scale(1)}}
.ksp-pop{animation:ksp-pop .45s var(--ease-kit)}
@keyframes ksp-burst{0%{opacity:0;transform:scale(.4)}25%{opacity:1;transform:scale(1.1)}70%{opacity:1;transform:scale(1)}100%{opacity:0;transform:scale(1.25)}}
.ksp-burst{animation:ksp-burst .9s var(--ease-kit) both}
@keyframes ksp-fill{from{transform:scaleX(0)}to{transform:scaleX(1)}}
@media (prefers-reduced-motion:reduce){.ksp-pop,.ksp-burst{animation:none}}
`;
function useCompact() {
const locale = useLocale();
const tag = locale === 'de' ? 'de-DE' : 'en-US';
// Below ten thousand the full number reads better (1,284 / 1.284), above it the short form (18.4K / 18.400 → 18.400 stays full in German up to a million)
return (n: number) => (n < 10000 ? new Intl.NumberFormat(tag).format(n) : new Intl.NumberFormat(tag, { notation: 'compact', maximumFractionDigits: 1 }).format(n));
}
/** Text with #tags and @names in a quiet accent */
function Rich({ text, light }: { text: string; light?: boolean }) {
const parts = text.split(/([#@][\p{L}\p{N}_]+)/u);
return (
<>
{parts.map((p, i) => (/^[#@]/.test(p) ? <span key={i} className={light ? 'font-semibold text-white' : 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]'}>{p}</span> : p))}
</>
);
}
function Avatar({ author, size = 36 }: { author: SocialAuthor; size?: number }) {
const initials = author.name
.split(' ')
.map((w) => w[0])
.join('')
.slice(0, 2);
return (
<span className={cn('relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--primary)_22%,var(--muted))] text-[12px] font-semibold text-foreground [&>*]:size-full')} style={{ width: size, height: size }}>
{author.avatar ?? initials}
</span>
);
}
function Name({ author, t, light }: { author: SocialAuthor; t: Texts; light?: boolean }) {
return (
<span className="flex min-w-0 items-center gap-1">
<span className={cn('truncate text-[14px] font-semibold', light ? 'text-white' : 'text-foreground')}>{author.name}</span>
{author.verified && (
<span role="img" aria-label={t.verified} className={cn('grid shrink-0 place-items-center', light ? 'text-white' : 'text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]')}>
<Icon name="check-circle" size={15} strokeWidth={2.2} />
</span>
)}
</span>
);
}
/** Heart that pops and counts. Shared by every format. */
function useLike(start: number, defaultLiked = false) {
const [liked, setLiked] = useState(defaultLiked);
const [pop, setPop] = useState(0);
const toggle = (force?: boolean) => {
setLiked((l) => (force === undefined ? !l : force));
setPop((p) => p + 1);
};
return { liked, count: start + (liked ? 1 : 0), pop, toggle };
}
function LikeButton({ like, t, light, label }: { like: ReturnType<typeof useLike>; t: Texts; light?: boolean; label?: string }) {
return (
<button
type="button"
aria-pressed={like.liked}
aria-label={like.liked ? t.unlike : t.like}
onClick={() => like.toggle()}
className={cn('group/like flex items-center gap-1.5 rounded-full outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[var(--ring)]', 'h-11 px-2.5 -ml-2.5', light ? 'text-white' : 'text-foreground')}
>
<span key={like.pop} className={cn('grid', like.pop > 0 && 'ksp-pop')}>
<Icon name="heart" size={22} className={cn('transition-colors duration-200', like.liked ? (light ? 'fill-white text-white' : 'fill-[var(--primary)] text-primary') : 'group-hover/like:text-muted-foreground')} />
</span>
{label !== undefined && <span className={cn('text-[13px] font-semibold tabular-nums')}>{label}</span>}
</button>
);
}
function ActionIcon({ icon, label, light, onClick, pressed, count }: { icon: 'chat' | 'bookmark' | 'repeat' | 'share'; label: string; light?: boolean; onClick?: () => void; pressed?: boolean; count?: string }) {
// Without a handler it is a picture of an action: icon and count, no button that does nothing
if (!onClick)
return (
<span className={cn('flex items-center gap-1.5', 'h-11 min-w-11 justify-center px-2.5', light ? 'text-white' : 'text-foreground')}>
<Icon name={icon} size={22} />
{count !== undefined && <span className={cn('text-[13px] font-semibold tabular-nums')}>{count}</span>}
<span className="sr-only">{label}</span>
</span>
);
return (
<button
type="button"
aria-label={label}
aria-pressed={pressed}
onClick={onClick}
className={cn('flex items-center gap-1.5 rounded-full outline-none transition-colors focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[var(--ring)]', 'h-11 min-w-11 justify-center px-2.5', light ? 'text-white' : 'text-foreground hover:text-muted-foreground')}
>
<Icon name={icon} size={22} className={cn('transition-transform duration-300', pressed && 'fill-current')} />
{count !== undefined && <span className={cn('text-[13px] font-semibold tabular-nums')}>{count}</span>}
</button>
);
}
/** Double tap anywhere on the picture likes it, with a big heart */
function TapLike({ onLike, children, className }: { onLike: () => void; children: React.ReactNode; className?: string }) {
const [burst, setBurst] = useState(0);
const last = useRef(0);
return (
<div
className={cn('relative select-none', className)}
onPointerUp={() => {
const now = Date.now();
if (now - last.current < 320) {
onLike();
setBurst((b) => b + 1);
}
last.current = now;
}}
>
{children}
{burst > 0 && (
<span key={burst} aria-hidden className="ksp-burst pointer-events-none absolute inset-0 grid place-items-center">
<Icon name="heart" size={96} className="fill-white text-white drop-shadow-[0_6px_24px_rgb(0_0_0/0.35)]" />
</span>
)}
</div>
);
}
/** A picture filling its frame. Drawn motifs may reach past it on purpose, the frame clips them. */
const fill = (n: React.ReactNode) => <div data-clip-ok className="absolute inset-0 [&>*]:size-full [&>img]:object-cover">{n}</div>;
/* ------------------------------------------------------------------ */
/**
* A social post as a picture for a page.
* <SocialPost variant="feed-post" author={{ name: 'Café Mira', handle: 'cafemira' }} media={[<img … />]} caption="…" likes={1240} />
*/
export function SocialPost(props: SocialPostProps) {
const { variant = 'feed-post', author, caption, media = [], overlays = [], likes = 0, comments = 0, shares = 0, time, commentList = [], card, sound, bio, stats = [], duration = 5, defaultLiked, onAction, labels, className } = props;
const act = (a: SocialAction) => (onAction ? () => onAction(a) : undefined);
const [reposted, setReposted] = useState(false);
const t = useTexts(TEXTS, labels);
const compact = useCompact();
const like = useLike(likes, defaultLiked);
const [saved, setSaved] = useState(false);
const [follow, setFollow] = useState(false);
const [view, inView] = useInView<HTMLDivElement>();
const styles = (
<style href="kit-social-post" precedence="default">
{STYLES}
</style>
);
const followBtn = (light?: boolean) => (
<button
type="button"
aria-pressed={follow}
onClick={() => setFollow((f) => !f)}
className={cn(
'h-8 shrink-0 rounded-lg px-3 text-[13px] font-semibold outline-none transition-colors focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[var(--ring)] max-sm:h-11',
follow ? (light ? 'border border-white/60 text-white' : 'bg-muted text-foreground') : light ? 'bg-white text-black' : 'bg-primary text-primary-foreground',
)}
>
{follow ? t.following : t.follow}
</button>
);
const shell = cn('overflow-hidden rounded-2xl border border-border bg-card text-card-foreground shadow-[var(--highlight),var(--shadow-md)]', className);
/* ---------- text-post ---------- */
if (variant === 'text-post') {
return (
<article className={cn(shell, 'p-4 sm:p-5')}>
{styles}
<div className="flex gap-3">
<Avatar author={author} size={44} />
<div className="min-w-0 flex-1">
<div className="leading-tight">
<Name author={author} t={t} />
<p className="truncate text-[12.5px] text-muted-foreground">{time ? `${author.handle} · ${time}` : author.handle}</p>
</div>
{caption && <p className="mt-2.5 whitespace-pre-line text-pretty text-[15px] leading-relaxed text-foreground"><Rich text={caption} /></p>}
{card && (
<a href={card.href} className="group/card mt-3 block overflow-hidden rounded-xl border border-border outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[var(--ring)]">
{card.media && <div className="relative aspect-[1.91/1] overflow-hidden bg-muted [&>*]:transition-transform [&>*]:duration-700 group-hover/card:[&>*]:scale-[1.03]">{fill(card.media)}</div>}
<div className="border-t border-border px-3.5 py-2.5">
<p className="text-[12px] text-muted-foreground">{card.domain}</p>
<p className="truncate text-[14px] font-medium text-foreground">{card.title}</p>
</div>
</a>
)}
<div className="-ml-2.5 mt-2 flex items-center text-muted-foreground">
<LikeButton like={like} t={t} label={compact(like.count)} />
<ActionIcon icon="chat" label={t.comment} count={compact(comments)} onClick={act('comment')} />
<ActionIcon icon="repeat" label={t.repost} count={compact(shares + (reposted ? 1 : 0))} pressed={reposted} onClick={() => setReposted((r) => !r)} />
<span className="flex-1" />
<ActionIcon icon="bookmark" label={saved ? t.saved : t.save} pressed={saved} onClick={() => setSaved((s) => !s)} />
</div>
</div>
</div>
</article>
);
}
/* ---------- story ---------- */
if (variant === 'story') return <Story {...props} t={t} styles={styles} followBtn={followBtn} />;
/* ---------- video-reel ---------- */
if (variant === 'video-reel') return <Reel {...props} t={t} styles={styles} like={like} compact={compact} followBtn={followBtn} saved={saved} setSaved={setSaved} />;
/* ---------- profile-grid ---------- */
if (variant === 'profile-grid') {
return (
<article ref={view} className={cn(shell, 'p-4 sm:p-6')}>
{styles}
<div className="flex items-center gap-4">
<Avatar author={author} size={64} />
<div className="min-w-0 flex-1 leading-tight">
<Name author={author} t={t} />
<p className="truncate text-[13px] text-muted-foreground">{author.handle}</p>
</div>
</div>
{bio && <p className="mt-4 whitespace-pre-line text-pretty text-[14px] leading-relaxed text-muted-foreground"><Rich text={bio} /></p>}
{stats.length > 0 && (
<dl className="mt-4 grid grid-cols-3 gap-px overflow-hidden rounded-xl border border-border bg-border">
{stats.slice(0, 3).map((s) => (
<div key={s.label} className="flex flex-col-reverse bg-card px-3 py-2.5">
<dt className="truncate text-[12px] text-muted-foreground">{s.label}</dt>
<dd className="text-[16px] font-semibold tabular-nums text-foreground">{compact(s.value)}</dd>
</div>
))}
</dl>
)}
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" aria-pressed={follow} onClick={() => setFollow((f) => !f)} className={cn('h-11 rounded-lg text-[14px] font-semibold transition-colors sm:h-9', follow ? 'bg-muted text-foreground' : 'bg-primary text-primary-foreground')}>
{follow ? t.following : t.follow}
</button>
{onAction ? (
<button type="button" onClick={() => onAction('message')} className="h-11 rounded-lg bg-muted text-[14px] font-semibold text-foreground transition-colors hover:bg-accent sm:h-9">
{t.message}
</button>
) : (
<span className="grid h-11 place-items-center rounded-lg bg-muted text-[14px] font-semibold text-foreground sm:h-9">{t.message}</span>
)}
</div>
<ul aria-label={t.posts} className="mt-5 grid grid-cols-3 gap-1.5">
{media.map((m, i) => (
<li
key={i}
className={cn('group/tile relative aspect-square overflow-hidden rounded-lg bg-muted transition-[opacity,transform] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && 'scale-95 opacity-0')}
style={{ transitionDelay: inView ? `${i * 55}ms` : '0ms' }}
>
{fill(m)}
<span className="absolute inset-0 flex flex-wrap content-center items-center justify-center gap-x-2.5 gap-y-1 bg-black/45 p-1 text-[12px] font-semibold text-white opacity-0 transition-opacity duration-300 group-hover/tile:opacity-100 sm:text-[13px]">
<span className="flex items-center gap-1">
<Icon name="heart" size={16} className="fill-white" />
{compact(round(likes / (i + 2)))}
</span>
<span className="flex items-center gap-1">
<Icon name="chat" size={16} className="fill-white" />
{compact(round(comments / (i + 1)))}
</span>
</span>
</li>
))}
</ul>
</article>
);
}
/* ---------- feed-post and carousel share the frame ---------- */
const slides = variant === 'carousel' ? media : media.slice(0, 1);
return (
<article className={shell}>
{styles}
<header className="flex items-center gap-3 px-4 py-3">
<Avatar author={author} />
<div className="min-w-0 flex-1 leading-tight">
<Name author={author} t={t} />
<p className="truncate text-[12px] text-muted-foreground">{time ? `${author.handle} · ${time}` : author.handle}</p>
</div>
{followBtn()}
{onAction ? (
<button type="button" aria-label={t.more} onClick={() => onAction('more')} className="-mr-2 grid size-11 place-items-center rounded-full text-muted-foreground outline-none hover:text-foreground focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[var(--ring)]">
<Icon name="more-horizontal" size={20} />
</button>
) : (
<span aria-hidden className="-mr-2 grid size-11 place-items-center text-muted-foreground">
<Icon name="more-horizontal" size={20} />
</span>
)}
</header>
{variant === 'carousel' ? <Slides slides={slides} overlays={overlays} t={t} onLike={() => like.toggle(true)} /> : (
<TapLike onLike={() => like.toggle(true)} className="aspect-[4/5] overflow-hidden bg-muted">
<div ref={view} className={cn('absolute inset-0 transition-transform duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && 'scale-[1.06]')}>
{fill(slides[0])}
</div>
{overlays[0] && <div aria-hidden className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/50 to-transparent" />}
{overlays[0] && <p className="absolute inset-x-5 bottom-5 text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-white drop-shadow-[0_2px_12px_rgb(0_0_0/0.45)]">{overlays[0]}</p>}
</TapLike>
)}
<div className="px-4 pb-4 pt-1">
{caption && (
<p className="mt-2 whitespace-pre-line text-pretty text-[14px] leading-relaxed text-foreground">
<Rich text={caption} />
</p>
)}
<div className="mt-1 flex items-center border-b border-border pb-1 text-muted-foreground">
<LikeButton like={like} t={t} label={compact(like.count)} />
<ActionIcon icon="chat" label={t.comment} count={compact(comments)} onClick={act('comment')} />
<ActionIcon icon="share" label={t.share} onClick={act('share')} />
<span className="flex-1" />
<ActionIcon icon="bookmark" label={saved ? t.saved : t.save} pressed={saved} onClick={() => setSaved((s) => !s)} />
</div>
{commentList.length > 0 && (
<ul className="mt-3 space-y-2">
{commentList.map((c) => (
<li key={c.name + c.text} className="text-[13.5px] leading-snug text-foreground">
<span className="block text-[12px] font-semibold text-muted-foreground">{c.name}</span>
<Rich text={c.text} />
</li>
))}
</ul>
)}
{comments > commentList.length && <p className="mt-2 text-[13px] text-muted-foreground">{t.viewAll.replace('{n}', compact(comments))}</p>}
</div>
</article>
);
}
/* ------------------------------------------------------------------ */
/* Carousel slides: swipe, drag, arrows, dots */
/* ------------------------------------------------------------------ */
function Slides({ slides, overlays, t, onLike }: { slides: React.ReactNode[]; overlays: string[]; t: Texts; onLike: () => void }) {
const [i, setI] = useState(0);
const [drag, setDrag] = useState(0);
const start = useRef<{ x: number; w: number } | null>(null);
const n = Math.max(1, slides.length);
const go = (to: number) => setI(Math.min(n - 1, Math.max(0, to)));
return (
<div
role="group"
aria-roledescription="carousel"
aria-label={`${t.slide} ${i + 1} / ${n}`}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') go(i + 1);
if (e.key === 'ArrowLeft') go(i - 1);
}}
className="group/car relative aspect-square touch-pan-y overflow-hidden bg-muted outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--ring)]"
onPointerDown={(e) => {
start.current = { x: e.clientX, w: e.currentTarget.clientWidth };
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => start.current && setDrag(e.clientX - start.current.x)}
onPointerUp={(e) => {
const s = start.current;
start.current = null;
if (!s) return;
const d = e.clientX - s.x;
if (Math.abs(d) > s.w * 0.18) go(i + (d < 0 ? 1 : -1));
setDrag(0);
}}
onPointerCancel={() => {
start.current = null;
setDrag(0);
}}
>
<TapLike onLike={onLike} className="absolute inset-0">
<div className={cn('flex h-full', !drag && 'transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none')} style={{ transform: `translateX(calc(${-i * 100}% + ${round(drag)}px))` }}>
{slides.map((m, k) => (
<div key={k} aria-hidden={k !== i} className="relative h-full w-full shrink-0">
{fill(m)}
{overlays[k] && <div aria-hidden className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/55 to-transparent" />}
{overlays[k] && (
<p className={cn('absolute inset-x-6 bottom-6 text-balance text-[clamp(1.25rem,4vw,1.75rem)] font-semibold leading-[1.1] tracking-[-0.025em] text-white drop-shadow-[0_2px_14px_rgb(0_0_0/0.4)] transition-[opacity,transform] duration-700 ease-[var(--ease-kit)]', k === i ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0')}>
{overlays[k]}
</p>
)}
</div>
))}
</div>
</TapLike>
<span className="absolute right-3 top-3 rounded-full bg-black/55 px-2.5 py-1 text-[12px] font-medium tabular-nums text-white">
{i + 1}/{n}
</span>
{(['prev', 'next'] as const).map((d) => (
<button
key={d}
type="button"
aria-label={t[d]}
onClick={() => go(i + (d === 'next' ? 1 : -1))}
disabled={d === 'prev' ? i === 0 : i === n - 1}
className={cn('absolute top-1/2 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white/85 text-black shadow-[var(--shadow-md)] opacity-0 transition-opacity duration-300 group-hover/car:opacity-100 focus-visible:opacity-100 disabled:!opacity-0 max-sm:hidden', d === 'prev' ? 'left-3' : 'right-3')}
>
<Icon name={d === 'prev' ? 'chevron-left' : 'chevron-right'} size={20} />
</button>
))}
<div aria-hidden className="absolute inset-x-0 bottom-3 flex justify-center gap-1.5">
{slides.map((_, k) => (
<span key={k} className={cn('size-1.5 rounded-full transition-[background-color,transform] duration-300', k === i ? 'scale-125 bg-white' : 'bg-white/50')} />
))}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Story: segment bars that fill, tap to move, pause on hold */
/* ------------------------------------------------------------------ */
function Story({ author, media = [], overlays = [], time, duration = 5, onAction, className, t, styles, followBtn }: SocialPostProps & { t: Texts; styles: React.ReactNode; followBtn: (light?: boolean) => React.ReactNode }) {
const n = Math.max(1, media.length);
const [i, setI] = useState(0);
const [held, setHeld] = useState(false);
const [run, setRun] = useState(false);
const [view, inView] = useInView<HTMLDivElement>();
// Plays only in the browser, in view and without reduced motion. The server shows the first frame.
useEffect(() => {
setRun(inView && !prefersReducedMotion());
}, [inView]);
useEffect(() => {
if (!run || held) return;
const id = window.setTimeout(() => setI((x) => (x + 1) % n), duration * 1000);
return () => window.clearTimeout(id);
}, [run, held, i, n, duration]);
return (
<article ref={view} className={cn('relative aspect-[9/16] w-full overflow-hidden rounded-[1.75rem] border border-border bg-black text-white shadow-[var(--shadow-lg)]', className)}>
{styles}
{media.map((m, k) => (
<div key={k} aria-hidden={k !== i} className={cn('absolute inset-0 transition-[opacity,visibility] duration-500', k === i ? 'opacity-100' : 'invisible opacity-0')}>
<div className={cn('absolute inset-0', k === i && run && 'animate-[kit-float_9s_ease-in-out_infinite] scale-[1.06]')}>{fill(m)}</div>
{overlays[k] && <div aria-hidden className="absolute inset-0 bg-[radial-gradient(70%_32%_at_50%_50%,rgb(0_0_0/0.42),transparent)]" />}
{overlays[k] && (
<p key={k === i ? `on${i}` : k} className={cn('absolute inset-x-6 top-1/2 -translate-y-1/2 text-balance text-center text-[clamp(1.5rem,5vw,2rem)] font-semibold leading-[1.1] tracking-[-0.025em] drop-shadow-[0_2px_16px_rgb(0_0_0/0.5)]', k === i && 'kit-rise')}>
{overlays[k]}
</p>
)}
</div>
))}
<div aria-hidden className="absolute inset-x-0 top-0 h-28 bg-gradient-to-b from-black/55 to-transparent" />
<div className="absolute inset-x-3 top-3 flex gap-1" aria-hidden>
{media.map((_, k) => (
<span key={k} className="h-[3px] flex-1 overflow-hidden rounded-full bg-white/35">
<span
key={k === i ? `a${i}` : k}
className="block h-full origin-left rounded-full bg-white"
style={k === i && run ? { animation: `ksp-fill ${duration}s linear both`, animationPlayState: held ? 'paused' : 'running' } : { transform: `scaleX(${k < i || (k === i && !run) ? 1 : 0})` }}
/>
</span>
))}
</div>
<header className="absolute inset-x-3 top-6 flex items-center gap-2.5">
<Avatar author={author} size={34} />
<div className="min-w-0 flex-1 leading-tight">
<Name author={author} t={t} light />
{time && <p className="text-[12px] text-white/75">{time}</p>}
</div>
{followBtn(true)}
</header>
{/* Tap zones: left third goes back, the rest goes on. Holding pauses. */}
<button type="button" aria-label={t.prev} onClick={() => setI((x) => Math.max(0, x - 1))} onPointerDown={() => setHeld(true)} onPointerUp={() => setHeld(false)} onPointerLeave={() => setHeld(false)} className="absolute bottom-20 left-0 top-20 w-1/3 outline-none focus-visible:bg-white/10 focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white/80" />
<button type="button" aria-label={t.next} onClick={() => setI((x) => (x + 1) % n)} onPointerDown={() => setHeld(true)} onPointerUp={() => setHeld(false)} onPointerLeave={() => setHeld(false)} className="absolute bottom-20 right-0 top-20 w-2/3 outline-none focus-visible:bg-white/10 focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white/80" />
<form
onSubmit={(e) => {
e.preventDefault();
const field = e.currentTarget.elements.namedItem('reply') as HTMLInputElement | null;
if (!field?.value.trim()) return;
onAction?.('reply', field.value.trim());
field.value = '';
}}
className="absolute inset-x-3 bottom-3 flex items-center gap-2"
>
<label className="sr-only" htmlFor={`story-${author.handle}`}>
{t.reply.replace('{name}', author.name)}
</label>
<input id={`story-${author.handle}`} name="reply" autoComplete="off" placeholder={t.reply.replace('{name}', author.name)} className="h-11 w-0 min-w-0 flex-1 rounded-full border border-white/50 bg-black/20 px-4 text-[14px] text-white outline-none backdrop-blur placeholder:text-white/75 focus:border-white focus:shadow-[0_0_0_3px_rgb(255_255_255/0.35)]" />
<button type="submit" aria-label={t.send} className="grid size-11 shrink-0 place-items-center rounded-full text-white">
<Icon name="send" size={22} />
</button>
</form>
</article>
);
}
/* ------------------------------------------------------------------ */
/* Reel: a vertical video with an action bar under the caption */
/* ------------------------------------------------------------------ */
function Reel({ author, caption, media = [], comments = 0, shares = 0, sound, onAction, className, t, styles, like, compact, followBtn, saved, setSaved }: SocialPostProps & { t: Texts; styles: React.ReactNode; like: ReturnType<typeof useLike>; compact: (n: number) => string; followBtn: (light?: boolean) => React.ReactNode; saved: boolean; setSaved: (f: (s: boolean) => boolean) => void }) {
const [playing, setPlaying] = useState(false);
const [view, inView] = useInView<HTMLDivElement>();
useEffect(() => {
if (inView && !prefersReducedMotion()) setPlaying(true);
}, [inView]);
return (
<article ref={view} className={cn('relative aspect-[9/16] w-full overflow-hidden rounded-[1.75rem] border border-border bg-black text-white shadow-[var(--shadow-lg)]', className)}>
{styles}
<TapLike onLike={() => like.toggle(true)} className="absolute inset-0">
<div className={cn('absolute inset-0', playing && 'animate-[kit-float_6s_ease-in-out_infinite] scale-[1.08]')}>{fill(media[0])}</div>
</TapLike>
<div aria-hidden className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/70 to-transparent" />
<button type="button" aria-label={playing ? t.pause : t.play} aria-pressed={!playing} onClick={() => setPlaying((p) => !p)} className={cn('absolute left-1/2 top-1/2 grid size-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-black/45 text-white backdrop-blur transition-opacity duration-300', playing ? 'opacity-0 hover:opacity-100 focus-visible:opacity-100' : 'opacity-100')}>
<Icon name={playing ? 'pause' : 'play'} size={28} className="fill-white" />
</button>
<div className="absolute inset-x-4 bottom-5">
<div className="flex items-center gap-2.5">
<Avatar author={author} size={34} />
<Name author={author} t={t} light />
{followBtn(true)}
</div>
{caption && <p className="mt-2.5 line-clamp-2 text-pretty text-[14px] leading-snug text-white/95"><Rich text={caption} light /></p>}
{sound && (
<p className="mt-2 flex items-center gap-1.5 overflow-hidden text-[12px] text-white/85">
<Icon name="music" size={13} className={cn(playing && 'animate-[kit-spin_4s_linear_infinite]')} />
<span className="truncate">{sound}</span>
</p>
)}
<div className="mt-3 flex items-center rounded-2xl border border-white/20 bg-black/30 pl-3 pr-1 backdrop-blur-md">
<LikeButton like={like} t={t} light label={compact(like.count)} />
<ActionIcon icon="chat" label={t.comment} light count={compact(comments)} onClick={onAction && (() => onAction('comment'))} />
<ActionIcon icon="share" label={t.share} light count={compact(shares)} onClick={onAction && (() => onAction('share'))} />
<span className="flex-1" />
<ActionIcon icon="bookmark" label={saved ? t.saved : t.save} light pressed={saved} onClick={() => setSaved((s) => !s)} />
</div>
</div>
<span aria-hidden className="absolute inset-x-0 bottom-0 h-[3px] bg-white/25">
<span className="block h-full origin-left bg-white" style={playing ? { animation: 'ksp-fill 12s linear infinite' } : { transform: 'scaleX(0.35)' }} />
</span>
</article>
);
}Frequent questions
How do I install Social post mockup?
With aiw add @ai-website/social-post-mockup. The command puts components/ui/social-post-mockup.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Social post mockup have?
6 variants, Picture post, Text post, Story, Carousel, Video reel and Profile grid. You pick one with the variant prop, for example variant="text-post".
Can I translate the texts of Social post mockup?
Yes. Social post mockup carries 21 texts of its own in English and German, such as “Like”, “Remove like” and “Comment”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Social post mockup?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Social post mockup with aiw_components and install it with aiw_add @ai-website/social-post-mockup. Name the variant in your prompt, for example variant="feed-post", and the assistant writes your content straight into the props.
Can I adjust Social post mockup without code?
Yes, in the Studio. All 6 variants are editable there, texts, lists and switches. The Studio writes the matching SocialPost code to copy.