Store-Screenshots
Die hohen Rahmen mit Überschrift und Telefon, die App-Seiten in einer Reihe zeigen, ohne Zeichen eines Stores. Überschriften, ein Panorama über alle Rahmen, geneigte Telefone, aufspringende Hinweise und breite Tablet-Rahmen. Die Reihe scrollt per Ziehen, Pfeilen und Tastatur.
Eine von 3 Components in Kampagnen-Mockups
Variants
Details
Was Store-Screenshots mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Kampagnen-Mockups
- Bereich
- Visuals
- Variants
- 5
- Version
- 1.0.1 · seit 1.1.0
- Datei
- components/ui/store-screenshots.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 3 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle StoreScreenshotsProps aus components/ui/store-screenshots.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalStoreScreenshotsVariantcaptionspanoramatiltedbadgestablet
shotsPflichtStoreShot[]toneoptional'accent' | 'dark' | 'light'accent (default) tints the frames with the accent, dark and light are neutral
viewportoptionalnumberWidth of the screen the drawn screens are made for. Default 390.
Eingebaute Texte
Diese Texte schreibt Store-Screenshots selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 3 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| prev | Englisch Previous screenshots | Deutsch Vorige Screenshots |
| next | Englisch Next screenshots | Deutsch Nächste Screenshots |
| list | Englisch Screenshots | Deutsch Screenshots |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Store-Screenshots reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
- 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, 275 Zeilen
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Parallax, useScrollFade } from '@/components/ui/motion';
import { PhoneMockup } from '@/components/ui/mockup';
/*
* Store screenshots for an app page: the tall frames with a headline and a
* phone that app stores and landing pages show in a row. No store marks, just
* the frames, ready for your own screens.
*
* captions a headline on a tinted ground, the phone rising from the bottom edge
* panorama one long picture across all frames, phones that cross the gaps
* tilted the phone at a slight angle, the headline at the bottom, hover straightens it
* badges small notes that pop up above the phone: a rating, a goal, a new feature
* tablet wide frames with a tablet for tablet sets
*
* The row scrolls sideways with drag, arrows and the keyboard, without a
* visible scrollbar, and fades only where more frames follow.
*/
export type StoreScreenshotsVariant = 'captions' | 'panorama' | 'tilted' | 'badges' | 'tablet';
export interface StoreShot {
title: string;
subtitle?: string;
/** The app screen inside the device */
screen?: React.ReactNode;
/** badges: up to three short notes around the phone */
badges?: string[];
}
export interface StoreScreenshotsProps {
variant?: StoreScreenshotsVariant;
shots: StoreShot[];
/** accent (default) tints the frames with the accent, dark and light are neutral */
tone?: 'accent' | 'dark' | 'light';
/** Width of the screen the drawn screens are made for. Default 390. */
viewport?: number;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: { prev: 'Previous screenshots', next: 'Next screenshots', list: 'Screenshots' },
de: { prev: 'Vorige Screenshots', next: 'Nächste Screenshots', list: 'Screenshots' },
};
/** One wide picture for the panorama, cut into the frames by background position */
function panorama(tone: StoreScreenshotsProps['tone']) {
if (tone === 'dark') return 'linear-gradient(100deg, oklch(0.2 0.02 264), oklch(0.28 0.04 264) 50%, oklch(0.18 0.02 264))';
if (tone === 'light') return 'linear-gradient(100deg, oklch(0.97 0.01 264), oklch(0.92 0.03 264) 50%, oklch(0.97 0.01 264))';
return 'linear-gradient(100deg, color-mix(in oklab, var(--primary) 80%, black), color-mix(in oklab, var(--primary-2) 75%, black) 50%, color-mix(in oklab, var(--primary) 55%, black))';
}
/** Frame ground per tone. The frames stay the same in light and dark mode, like printed pictures. */
function ground(tone: StoreScreenshotsProps['tone'], i: number) {
if (tone === 'dark') return { bg: 'linear-gradient(180deg, oklch(0.24 0.02 264), oklch(0.16 0.015 264))', fg: 'oklch(0.98 0 none)', sub: 'oklch(0.8 0.01 264)' };
if (tone === 'light') return { bg: 'linear-gradient(180deg, oklch(0.975 0.005 264), oklch(0.93 0.01 264))', fg: 'oklch(0.2 0.02 264)', sub: 'oklch(0.45 0.02 264)' };
const a = i % 2 ? 'var(--primary-2)' : 'var(--primary)';
return { bg: `linear-gradient(180deg, color-mix(in oklab, ${a} 88%, black), color-mix(in oklab, ${a} 55%, black))`, fg: 'oklch(0.99 0 none)', sub: 'oklch(0.99 0 none / 0.78)' };
}
function Row({ children, label, t, frameWidth }: { children: React.ReactNode; label: string; t: (typeof TEXTS)['en']; frameWidth: number }) {
const [ref, fade] = useScrollFade<HTMLUListElement>();
const drag = useRef<{ x: number; left: number; moved: boolean } | null>(null);
const [canPrev, setPrev] = useState(false);
const [canNext, setNext] = useState(true);
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () => {
setPrev(el.scrollLeft > 2);
setNext(el.scrollLeft < el.scrollWidth - el.clientWidth - 2);
};
update();
el.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
return () => {
el.removeEventListener('scroll', update);
window.removeEventListener('resize', update);
};
}, [ref]);
const by = (dir: number) => ref.current?.scrollBy({ left: dir * (frameWidth + 16) * 2, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
return (
<div className="relative">
<ul
ref={ref}
aria-label={label}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') by(1);
if (e.key === 'ArrowLeft') by(-1);
}}
onPointerDown={(e) => {
if (e.pointerType !== 'mouse' || !ref.current) return;
drag.current = { x: e.clientX, left: ref.current.scrollLeft, moved: false };
}}
onPointerMove={(e) => {
const d = drag.current;
if (!d || !ref.current) return;
const dx = e.clientX - d.x;
if (Math.abs(dx) > 4) d.moved = true;
ref.current.scrollLeft = d.left - dx;
}}
onPointerUp={() => (drag.current = null)}
onPointerLeave={() => (drag.current = null)}
className={cn('kit-no-scrollbar flex snap-x snap-mandatory gap-4 overflow-x-auto overscroll-x-contain pb-2 outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--ring)] [scroll-padding-inline:0px]', fade)}
>
{children}
</ul>
<div className="mt-5 flex justify-end gap-2">
{([-1, 1] as const).map((d) => (
<button
key={d}
type="button"
aria-label={d < 0 ? t.prev : t.next}
disabled={d < 0 ? !canPrev : !canNext}
onClick={() => by(d)}
className="grid size-11 place-items-center rounded-full border border-border bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)] transition-[opacity,transform,background-color] hover:bg-accent active:scale-95 disabled:opacity-35"
>
<Icon name={d < 0 ? 'arrow-left' : 'arrow-right'} size={18} />
</button>
))}
</div>
</div>
);
}
/**
* A small note that pops up right above the phone. The notes stand in the free band between the
* heading and the phone and travel with it, so they never cover the screen or the heading.
* They alternate left and right, which reads like notifications popping out of the device.
*/
function Badge({ text, i, show }: { text: string; i: number; show: boolean }) {
return (
<span
className={cn(
'max-w-full rounded-2xl bg-[oklch(0.99_0_none)] px-3 py-2 text-[12px] font-semibold leading-tight text-[oklch(0.2_0.02_264)] shadow-[0_10px_30px_-8px_rgb(0_0_0/0.45)] transition-[opacity,transform] duration-700 ease-[cubic-bezier(0.34,1.4,0.64,1)] motion-reduce:transition-none',
i % 2 ? 'self-end' : 'self-start',
show ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-3 scale-90 opacity-0',
)}
style={{ transitionDelay: show ? `${300 + i * 180}ms` : '0ms' }}
>
{text}
</span>
);
}
/** A plain tablet drawn around a screen, landscape */
function Tablet({ children, viewport }: { children: React.ReactNode; viewport: number }) {
const ref = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(0.5);
useEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(([e]) => setScale(round(e.contentRect.width / (viewport * 1.6), 4)));
ro.observe(el);
return () => ro.disconnect();
}, [viewport]);
return (
<div className="rounded-[1.4rem] bg-[oklch(0.16_0.01_264)] p-[3%] shadow-[0_24px_50px_-20px_rgb(0_0_0/0.6),inset_0_0_0_1px_rgb(255_255_255/0.08)]">
<div ref={ref} data-clip-ok className="relative aspect-[1.6/1] overflow-hidden rounded-[0.8rem] bg-background">
<div className="absolute left-0 top-0 origin-top-left" style={{ width: viewport * 1.6, height: `${round(100 / scale, 2)}%`, transform: `scale(${scale})` }}>
{children}
</div>
</div>
</div>
);
}
/**
* A row of store screenshot frames.
* <StoreScreenshots variant="captions" shots={[{ title: 'Plan your week in one minute', screen: <Screen /> }, …]} />
*/
export function StoreScreenshots({ variant = 'captions', shots, tone = 'accent', viewport = 390, labels, className }: StoreScreenshotsProps) {
const t = useTexts(TEXTS, labels);
const [ref, inView] = useInView<HTMLDivElement>();
const n = shots.length;
const wide = variant === 'tablet';
const frameWidth = wide ? 420 : 236;
const frame = (s: StoreShot, i: number) => {
const g = ground(tone, variant === 'panorama' ? 0 : i);
const phone = (
<PhoneMockup width="100%" viewport={viewport} finish="ink" time="8:30">
{s.screen}
</PhoneMockup>
);
const heading = (
<div className={cn('relative z-10', variant === 'tilted' ? 'px-5 pb-6' : 'px-5 pt-6')}>
<p className="text-balance text-[19px] font-semibold leading-[1.15] tracking-[-0.02em]" style={{ color: g.fg }}>
{s.title}
</p>
{s.subtitle && (
<p className="mt-1.5 text-pretty text-[12.5px] leading-snug" style={{ color: g.sub }}>
{s.subtitle}
</p>
)}
</div>
);
let inner: React.ReactNode;
if (variant === 'tablet') {
inner = (
<div className="flex h-full flex-col">
{heading}
<div className="mt-auto px-6 pb-6">
<Parallax speed={-24}>
<Tablet viewport={viewport}>{s.screen}</Tablet>
</Parallax>
</div>
</div>
);
} else if (variant === 'tilted') {
inner = (
<div className="flex h-full flex-col">
<div className="relative flex-1">
<div className={cn('absolute left-1/2 top-[7%] w-[70%] -translate-x-1/2 transition-transform duration-700 ease-[var(--ease-kit)] group-hover/shot:rotate-0 group-focus-within/shot:rotate-0 motion-reduce:transition-none', i % 2 ? 'rotate-[5deg]' : 'rotate-[-5deg]')}>{phone}</div>
</div>
{heading}
</div>
);
} else {
const notes = variant === 'badges' ? (s.badges ?? []).slice(0, 3) : [];
// With notes the phone moves down and gets a little narrower, the notes take the band above it
const lift = variant === 'panorama' ? (i % 2 ? 'top-[30%]' : 'top-[22%]') : notes.length > 2 ? 'top-[45%]' : notes.length ? 'top-[40%]' : 'top-[30%]';
inner = (
<>
{heading}
<div className={cn('absolute left-1/2 -translate-x-1/2', notes.length ? 'w-[72%]' : 'w-[76%]', lift)}>
<Parallax speed={variant === 'panorama' ? (i % 2 ? -40 : 30) : -28} className="relative">
{notes.length > 0 && (
<div className="absolute bottom-full left-[-9%] right-[-9%] z-10 mb-2 flex flex-col gap-1.5">
{notes.map((b, k) => <Badge key={b} text={b} i={k} show={inView} />)}
</div>
)}
{phone}
</Parallax>
</div>
</>
);
}
return (
<li
key={`${s.title}${i}`}
className={cn('group/shot relative shrink-0 snap-start overflow-hidden rounded-[1.6rem] shadow-[var(--shadow-md)] transition-[transform,opacity] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && 'translate-y-6 opacity-0')}
style={{
width: frameWidth,
aspectRatio: wide ? '4 / 3' : '9 / 19.5',
background: variant === 'panorama' ? panorama(tone) : g.bg,
backgroundSize: variant === 'panorama' ? `${n * 100}% 100%` : undefined,
backgroundPosition: variant === 'panorama' ? `${n > 1 ? round((i / (n - 1)) * 100, 3) : 0}% 0` : undefined,
transitionDelay: inView ? `${i * 90}ms` : '0ms',
}}
data-clip-ok
>
{variant === 'panorama' && (
<svg aria-hidden className="pointer-events-none absolute inset-0 size-full" viewBox="0 0 100 200" preserveAspectRatio="none">
{/* One long wave across all frames: each frame draws its own part */}
<path d={`M0 ${round(150 + Math.sin(i * 1.3) * 18, 2)} C 30 ${round(130 + Math.sin(i * 1.3 + 0.6) * 22, 2)}, 70 ${round(170 + Math.sin(i * 1.3 + 1) * 22, 2)}, 100 ${round(150 + Math.sin((i + 1) * 1.3) * 18, 2)} V200 H0Z`} fill="oklch(1 0 none / 0.1)" />
</svg>
)}
{inner}
</li>
);
};
return (
<div ref={ref} className={cn('w-full', className)}>
<Row label={t.list} t={t} frameWidth={frameWidth}>
{shots.map(frame)}
</Row>
</div>
);
}Häufige Fragen
Wie installiere ich Store-Screenshots?
Mit aiw add @ai-website/store-screenshots. Der Befehl legt die Datei components/ui/store-screenshots.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, motion und mockup. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Store-Screenshots?
5 Varianten, Überschriften, Panorama, Geneigt, Mit Hinweisen und Tablet. Du wählst sie mit der Prop variant, zum Beispiel variant="panorama".
Kann ich die Texte von Store-Screenshots übersetzen?
Ja. Store-Screenshots bringt 3 eigene Texte auf Deutsch und Englisch mit, etwa „Vorige Screenshots“, „Nächste Screenshots“ und „Screenshots“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Store-Screenshots einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Store-Screenshots mit aiw_components und installieren sie mit aiw_add @ai-website/store-screenshots. Nenn im Prompt die Variante, etwa variant="captions", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Store-Screenshots ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden StoreScreenshots-Code zum Kopieren.