Zum Inhalt springen
UI Kit
Accent

Social-Post-Mockup

Social Posts als Bilder für deine Seite, ohne das Zeichen eines Netzwerks. Bildbeiträge, Textbeiträge, Stories, Karussells, Video-Reels und ein Profilraster. Doppeltippen zum Liken, Speichern, Folgen, Bilder wischen und durch Stories tippen.

Eine von 3 Components in Kampagnen-Mockups

6 Variants

Variants

Bildbeitrag

Autor, Bild, Aktionen, Likes, Text und Kommentare. Das Bild setzt sich beim Hereinscrollen aus leichtem Zoom, Doppeltippen liked mit großem Herz.

Im Studio öffnen

Textbeitrag

Ein kurzer Beitrag mit verlinkter Karte und Antworten, Teilen, Liken und Speichern. Tags und Namen sind getönt, Zahlen kürzen sich je Sprache.

Im Studio öffnen

Story

Eine Story im Hochformat. Die Segmente füllen sich von selbst, sobald sie im Bild ist, links tippen geht zurück, rechts weiter, Halten pausiert.

Im Studio öffnen

Karussell

Ein quadratischer Beitrag mit fünf Bildern. Wischen oder ziehen, Pfeile beim Hover oder die Pfeiltasten. Die Überschrift jedes Bildes steigt beim Ankommen auf.

Im Studio öffnen

Video-Reel

Ein Videobeitrag im Hochformat mit Aktionen am Rand, Text und Tonzeile. Er startet im Bild, ein Tipp in die Mitte pausiert, die Note dreht sich beim Abspielen.

Im Studio öffnen

Profilraster

Ein Profilkopf mit Zahlen, Bio und Folgen über einem Raster aus Beiträgen. Die Kacheln steigen nacheinander auf, Hover zeigt Likes und Kommentare.

Im Studio öffnen

Details

Was Social-Post-Mockup mitbringt, aus Registry und Quellcode gelesen.

Bereich
Visuals
Variants
6
Version
1.0.1 · seit 1.1.0
Datei
components/ui/social-post-mockup.tsx
Bringt mit
utilskiticon
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 21 eigene Texte

Props

Die Schnittstelle SocialPostProps aus components/ui/social-post-mockup.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    SocialPostVariant

    feed-posttext-poststorycarouselvideo-reelprofile-grid

  • authorPflicht
    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.

Eingebaute Texte

Diese Texte schreibt Social-Post-Mockup selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 21 Texte anzeigen
likeEnglisch LikeDeutsch Gefällt mir
unlikeEnglisch Remove likeDeutsch Gefällt mir entfernen
commentEnglisch CommentDeutsch Kommentieren
shareEnglisch ShareDeutsch Teilen
saveEnglisch SaveDeutsch Speichern
savedEnglisch SavedDeutsch Gespeichert
followEnglisch FollowDeutsch Folgen
followingEnglisch FollowingDeutsch Du folgst
moreEnglisch MoreDeutsch Mehr
viewAllEnglisch Show {n} commentsDeutsch {n} Kommentare zeigen
replyEnglisch Reply to {name}Deutsch Antwort an {name}
playEnglisch PlayDeutsch Abspielen
pauseEnglisch PauseDeutsch Anhalten
prevEnglisch PreviousDeutsch Zurück
nextEnglisch NextDeutsch Weiter
slideEnglisch SlideDeutsch Bild
postsEnglisch PostsDeutsch Beiträge
repostEnglisch RepostDeutsch Weiterteilen
verifiedEnglisch VerifiedDeutsch Bestätigt
sendEnglisch SendDeutsch Senden
messageEnglisch MessageDeutsch Nachricht

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Social-Post-Mockup reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-label, aria-pressed und aria-roledescription, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen group und img.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 606 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Social-Post-Mockup?

Mit aiw add @ai-website/social-post-mockup. Der Befehl legt die Datei components/ui/social-post-mockup.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Social-Post-Mockup?

6 Varianten, Bildbeitrag, Textbeitrag, Story, Karussell, Video-Reel und Profilraster. Du wählst sie mit der Prop variant, zum Beispiel variant="text-post".

Kann ich die Texte von Social-Post-Mockup übersetzen?

Ja. Social-Post-Mockup bringt 21 eigene Texte auf Deutsch und Englisch mit, etwa „Gefällt mir“, „Gefällt mir entfernen“ und „Kommentieren“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Social-Post-Mockup einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Social-Post-Mockup mit aiw_components und installieren sie mit aiw_add @ai-website/social-post-mockup. Nenn im Prompt die Variante, etwa variant="feed-post", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Social-Post-Mockup ohne Code anpassen?

Ja, im Studio. Alle 6 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden SocialPost-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert