Skip to content
UI Kit
Accent color

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

6 variants

Variants

Picture post

Author, picture, actions, likes, caption and comments. The picture settles from a slight zoom as it scrolls in, a double tap likes it with a big heart.

Open in Studio

Text post

A short post with a linked card and reply, repost, like and save. Tags and names are tinted, counts shorten with the locale.

Open in Studio

Story

A vertical story. The segment bars fill on their own once it is in view, tap the left third to go back and the rest to go on, hold to pause.

Open in Studio

Carousel

A square post with five slides. Swipe or drag, use the arrows on hover or the arrow keys. The headline of each slide rises in as it arrives.

Open in Studio

Video reel

A vertical video post with side actions, caption and a sound line. It starts in view, a tap on the middle pauses, the note spins while it plays.

Open in Studio

Profile grid

A profile head with numbers, bio and follow, over a grid of posts. The tiles rise one after another, hover shows likes and comments.

Open in Studio

Details

What Social post mockup brings along, read from the registry and the source.

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

Props

The SocialPostProps interface from components/ui/social-post-mockup.tsx, with the comments from the code.

  • variantoptional
    SocialPostVariant

    feed-posttext-poststorycarouselvideo-reelprofile-grid

  • authorrequired
    SocialAuthor
  • captionoptional
    string

    Text of the post, #tags and @names are tinted

  • mediaoptional
    React.ReactNode[]

    Pictures: one for feed-post, several for carousel, story, profile-grid

  • overlaysoptional
    string[]

    Text over each slide or story frame, by index

  • likesoptional
    number
  • commentsoptional
    number
  • sharesoptional
    number
  • timeoptional
    string

    e.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

  • soundoptional
    string

    video-reel: the sound line

  • biooptional
    string

    profile-grid

  • statsoptional
    { label: string; value: number }[]
  • durationoptional
    number

    Seconds per story frame

  • defaultLikedoptional
    boolean

    Start liked (Studio preview)

  • onActionoptional
    (action: SocialAction, text?: string) => void

    Comment, 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
likeEnglish LikeGerman Gefällt mir
unlikeEnglish Remove likeGerman Gefällt mir entfernen
commentEnglish CommentGerman Kommentieren
shareEnglish ShareGerman Teilen
saveEnglish SaveGerman Speichern
savedEnglish SavedGerman Gespeichert
followEnglish FollowGerman Folgen
followingEnglish FollowingGerman Du folgst
moreEnglish MoreGerman Mehr
viewAllEnglish Show {n} commentsGerman {n} Kommentare zeigen
replyEnglish Reply to {name}German Antwort an {name}
playEnglish PlayGerman Abspielen
pauseEnglish PauseGerman Anhalten
prevEnglish PreviousGerman Zurück
nextEnglish NextGerman Weiter
slideEnglish SlideGerman Bild
postsEnglish PostsGerman Beiträge
repostEnglish RepostGerman Weiterteilen
verifiedEnglish VerifiedGerman Bestätigt
sendEnglish SendGerman Senden
messageEnglish MessageGerman 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
components/ui/social-post-mockup.tsx
'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.

Often searched

Jump to

Copied to the clipboard