Skip to content
UI Kit
Accent color

Store screenshots

The tall frames with a headline and a phone that app pages show in a row, without any store mark. Captions, a panorama across all frames, tilted phones, pop-up notes and wide tablet frames. The row scrolls with drag, arrows and the keyboard.

One of 3 components in Campaign mockups

5 variants

Variants

Captions

A headline on a tinted ground, the phone rising from the bottom edge and drifting a little as you scroll. Frames rise in one after another.

Open in Studio

Panorama

One long picture runs across all frames, so the set reads as one scene when it stands in a row. Phones sit at alternating heights and drift in opposite directions.

Open in Studio

Tilted

The phone leans a little, left and right in turn, the headline sits at the bottom. Hover or focus straightens it.

Open in Studio

With notes

Small notes pop up around the phone when the row comes into view, such as a rating, a reached goal or a new feature.

Open in Studio

Tablet

Wide frames with a tablet for sets in landscape. The tablet drifts gently while the row scrolls into view.

Open in Studio

Details

What Store screenshots brings along, read from the registry and the source.

Variants
5
Version
1.0.1 · since 1.1.0
File
components/ui/store-screenshots.tsx
Brings along
utilskiticonmotionmockup
npm packages
None
Languages
English and German, 3 texts of its own

Props

The StoreScreenshotsProps interface from components/ui/store-screenshots.tsx, with the comments from the code.

  • variantoptional
    StoreScreenshotsVariant

    captionspanoramatiltedbadgestablet

  • shotsrequired
    StoreShot[]
  • toneoptional
    'accent' | 'dark' | 'light'

    accent (default) tints the frames with the accent, dark and light are neutral

  • viewportoptional
    number

    Width of the screen the drawn screens are made for. Default 390.

Built-in texts

Store screenshots writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 3 texts
prevEnglish Previous screenshotsGerman Vorige Screenshots
nextEnglish Next screenshotsGerman Nächste Screenshots
listEnglish ScreenshotsGerman Screenshots

Accessibility

Read from the source of the component.

  • Store screenshots handles the keys Arrow left, Arrow right and Space itself.
  • Sets aria-label, so screen readers announce purpose and state.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Moves with the scroll, its progress follows the position on the page.
  • 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, 275 lines
components/ui/store-screenshots.tsx
'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>
  );
}

Frequent questions

How do I install Store screenshots?

With aiw add @ai-website/store-screenshots. The command puts components/ui/store-screenshots.tsx into your project, the code is yours from then on. It brings along icon, motion and mockup. It needs no further npm packages.

Which variants does Store screenshots have?

5 variants, Captions, Panorama, Tilted, With notes and Tablet. You pick one with the variant prop, for example variant="panorama".

Can I translate the texts of Store screenshots?

Yes. Store screenshots carries 3 texts of its own in English and German, such as “Previous screenshots”, “Next screenshots” and “Screenshots”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Store screenshots?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Store screenshots with aiw_components and install it with aiw_add @ai-website/store-screenshots. Name the variant in your prompt, for example variant="captions", and the assistant writes your content straight into the props.

Can I adjust Store screenshots without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching StoreScreenshots code to copy.

Often searched

Jump to

Copied to the clipboard