Skip to content
UI Kit
Accent color

Placeholder images

Placeholder pictures that look like the photo that will later sit in the slot. Seventeen industries, each with several shots, a caption chip and a size hint for builders. No files, no requests.

One of 3 components in Placeholders and generators

5 variants

Variants

Hospitality

Restaurant, café, bar and hotel. The cards rise one after another, the picture zooms on hover and the button swaps every shot.

Property listing

A listing with house and rooms. The photo grows into place while you scroll, the thumbnails switch it and the size hint shows 1600 px.

Health and wellness

Fitness, yoga, salon and practice. The switch glides to the industry and the three shots fade in anew.

Business and studio

Law firm, software, agency, course, event, shop and craft in a bento grid that rises in order.

All industries

The full set with a group filter. The size switch shows the pixel size each slot expects.

Details

What Placeholder images brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.0.0
File
components/ui/placeholder.tsx
Brings along
utilskitgraphicsphotos
npm packages
None
Languages
English and German, 18 texts of its own
Studio
Adjust in code

Props

The PlaceholderProps interface from components/ui/placeholder.tsx, with the comments from the code.

  • industryoptional
    PlaceholderIndustry

    restaurantcafebarreal-estatehotelfitnessyogasalonpracticelawcrafttravelsoftwareeducationeventsshopagency

  • seedoptional
    number

    Picks another shot of the same industry

  • aspectoptional
    string

    CSS aspect ratio, default "4 / 3". Pass "auto" to size it with className.

  • roundedoptional
    boolean

    Rounded corners and a hairline edge, default true

  • labeloptional
    React.ReactNode

    An optional caption chip in the lower left

  • showRatiooptional
    boolean

    A small size hint for builders in the upper right, e.g. 1200 × 900

  • ratioWidthoptional
    number

    Width the size hint is based on, default 1200

  • zoomoptional
    boolean

    Zooms the picture a little on hover, default false

  • cropoptional
    number

    Crops into the picture for a detail shot, 1 shows all of it (default), 2 shows a quarter

  • focusoptional
    string

    The point the crop centers on, as CSS position, default "50% 50%"

  • altoptional
    string

    Alternative text. Defaults to a short description of the industry, pass "" for a decorative image.

Built-in texts

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

Show all 18 texts
restaurantEnglish A dish on a restaurant tableGerman Ein Gericht auf einem Restauranttisch
cafeEnglish Coffee in a café in morning lightGerman Kaffee in einem Café im Morgenlicht
barEnglish A drink on the barGerman Ein Drink an der Bar
real-estateEnglish A house for saleGerman Ein Haus zum Verkauf
hotelEnglish A hotel room with a viewGerman Ein Hotelzimmer mit Aussicht
fitnessEnglish A training studioGerman Ein Trainingsstudio
yogaEnglish A calm yoga studioGerman Ein ruhiges Yogastudio
salonEnglish A salon with a mirror and a chairGerman Ein Salon mit Spiegel und Stuhl
practiceEnglish A bright treatment roomGerman Ein heller Behandlungsraum
lawEnglish An office with books and a deskGerman Ein Büro mit Büchern und Schreibtisch
craftEnglish A workshop bench with ceramicsGerman Eine Werkbank mit Keramik
travelEnglish A landscape for a tripGerman Eine Landschaft für eine Reise
softwareEnglish A software dashboardGerman Ein Software-Dashboard
educationEnglish A course coverGerman Ein Kurscover
eventsEnglish A conference stageGerman Eine Konferenzbühne
shopEnglish A small boutiqueGerman Eine kleine Boutique
agencyEnglish A brand identity boardGerman Ein Markenboard
ratioEnglish Image sizeGerman Bildgröße

Accessibility

Read from the source of the component.

  • Sets aria-label, so screen readers announce purpose and state.
  • Uses the role text.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 257 lines
components/ui/placeholder.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { AppScreen, TourScreen, type TourView } from '@/components/ui/graphics';
import {
  BrandBoard,
  CourseCover,
  PhotoCafe,
  PhotoCoast,
  PhotoDish,
  PhotoDrink,
  PhotoHouse,
  PhotoInterior,
  PhotoOffice,
  PhotoShop,
  PhotoStudio,
  PhotoTreatment,
  PhotoValley,
  PhotoWorkshop,
  StagePhoto,
  type DishKind,
  type DrinkKind,
  type HouseKind,
  type InteriorKind,
  type InteriorView,
  type StudioKind,
} from '@/components/ui/photos';

/*
 * Placeholder images per industry. They look like the photo that will later
 * sit in the slot, so a layout reads right before the real pictures arrive.
 * Drawn in SVG, no image files, no requests. The seed picks a different shot
 * of the same industry. Photo motifs keep their natural colours in both
 * themes, software and agency follow the tokens and the accent.
 */

export type PlaceholderIndustry =
  | 'restaurant'
  | 'cafe'
  | 'bar'
  | 'real-estate'
  | 'hotel'
  | 'fitness'
  | 'yoga'
  | 'salon'
  | 'practice'
  | 'law'
  | 'craft'
  | 'travel'
  | 'software'
  | 'education'
  | 'events'
  | 'shop'
  | 'agency';

export const PLACEHOLDER_INDUSTRIES: PlaceholderIndustry[] = ['restaurant', 'cafe', 'bar', 'real-estate', 'hotel', 'fitness', 'yoga', 'salon', 'practice', 'law', 'craft', 'travel', 'software', 'education', 'events', 'shop', 'agency'];

const TEXTS = {
  en: {
    restaurant: 'A dish on a restaurant table',
    cafe: 'Coffee in a café in morning light',
    bar: 'A drink on the bar',
    'real-estate': 'A house for sale',
    hotel: 'A hotel room with a view',
    fitness: 'A training studio',
    yoga: 'A calm yoga studio',
    salon: 'A salon with a mirror and a chair',
    practice: 'A bright treatment room',
    law: 'An office with books and a desk',
    craft: 'A workshop bench with ceramics',
    travel: 'A landscape for a trip',
    software: 'A software dashboard',
    education: 'A course cover',
    events: 'A conference stage',
    shop: 'A small boutique',
    agency: 'A brand identity board',
    ratio: 'Image size',
  },
  de: {
    restaurant: 'Ein Gericht auf einem Restauranttisch',
    cafe: 'Kaffee in einem Café im Morgenlicht',
    bar: 'Ein Drink an der Bar',
    'real-estate': 'Ein Haus zum Verkauf',
    hotel: 'Ein Hotelzimmer mit Aussicht',
    fitness: 'Ein Trainingsstudio',
    yoga: 'Ein ruhiges Yogastudio',
    salon: 'Ein Salon mit Spiegel und Stuhl',
    practice: 'Ein heller Behandlungsraum',
    law: 'Ein Büro mit Büchern und Schreibtisch',
    craft: 'Eine Werkbank mit Keramik',
    travel: 'Eine Landschaft für eine Reise',
    software: 'Ein Software-Dashboard',
    education: 'Ein Kurscover',
    events: 'Eine Konferenzbühne',
    shop: 'Eine kleine Boutique',
    agency: 'Ein Markenboard',
    ratio: 'Bildgröße',
  },
};

export type PlaceholderLabels = Partial<typeof TEXTS.en>;

export interface PlaceholderProps {
  industry?: PlaceholderIndustry;
  /** Picks another shot of the same industry */
  seed?: number;
  /** CSS aspect ratio, default "4 / 3". Pass "auto" to size it with className. */
  aspect?: string;
  /** Rounded corners and a hairline edge, default true */
  rounded?: boolean;
  /** An optional caption chip in the lower left */
  label?: React.ReactNode;
  /** A small size hint for builders in the upper right, e.g. 1200 × 900 */
  showRatio?: boolean;
  /** Width the size hint is based on, default 1200 */
  ratioWidth?: number;
  /** Zooms the picture a little on hover, default false */
  zoom?: boolean;
  /** Crops into the picture for a detail shot, 1 shows all of it (default), 2 shows a quarter */
  crop?: number;
  /** The point the crop centers on, as CSS position, default "50% 50%" */
  focus?: string;
  /** Alternative text. Defaults to a short description of the industry, pass "" for a decorative image. */
  alt?: string;
  labels?: PlaceholderLabels;
  className?: string;
}

const DISHES: DishKind[] = ['pasta', 'salad', 'fish', 'steak', 'risotto', 'bowl', 'soup', 'dessert'];
const DRINKS: DrinkKind[] = ['cocktail', 'wine', 'spritz', 'beer', 'red', 'lemonade'];
const HOUSES: HouseKind[] = ['villa', 'townhouse', 'cottage', 'apartment', 'loft'];
const ROOMS: [InteriorKind, InteriorView][] = [['suite', 'sea'], ['bedroom', 'mountains'], ['living', 'city'], ['bath', 'garden']];
const STUDIOS: StudioKind[] = ['strength', 'cycle', 'boxing'];
const CALM: StudioKind[] = ['yoga', 'pilates'];
const VIEWS: TourView[] = ['insights', 'board', 'automations', 'inbox'];

/** A product shot for software: the app window floats on a soft accent field */
function SoftwareShot({ seed }: { seed: number }) {
  return (
    <div className="absolute inset-0 bg-[radial-gradient(120%_90%_at_20%_0%,color-mix(in_oklab,var(--primary)_30%,var(--surface-2)),var(--surface)_70%)]">
      <div aria-hidden className="absolute inset-0 bg-[radial-gradient(color-mix(in_oklab,var(--foreground)_10%,transparent)_1px,transparent_1.4px)] [background-size:18px_18px] [mask-image:linear-gradient(to_bottom,#000,transparent_80%)]" />
      <svg viewBox="0 0 800 560" preserveAspectRatio="xMidYMin slice" className="absolute inset-0 size-full">
        <foreignObject x="90" y="56" width="620" height="560">
          <div className="h-full overflow-hidden rounded-xl border border-border bg-background shadow-[var(--shadow-lg)]">
            <div className="flex h-7 items-center gap-1.5 border-b border-border bg-surface px-3">
              {[0, 1, 2].map((i) => (
                <span key={i} className="size-2 rounded-full bg-border-strong" />
              ))}
            </div>
            {seed % 2 === 0 ? <AppScreen className="min-h-[520px]" /> : <TourScreen view={VIEWS[Math.floor(seed / 2) % VIEWS.length]} className="min-h-[520px]" />}
          </div>
        </foreignObject>
      </svg>
    </div>
  );
}

function art(industry: PlaceholderIndustry, seed: number) {
  const s = Math.abs(Math.round(seed));
  switch (industry) {
    case 'restaurant':
      return <PhotoDish dish={DISHES[s % DISHES.length]} table={s} />;
    case 'cafe':
      return s % 2 === 0 ? <PhotoCafe /> : <PhotoDrink kind="coffee" />;
    case 'bar':
      return <PhotoDrink kind={DRINKS[s % DRINKS.length]} />;
    case 'real-estate':
      return <PhotoHouse kind={HOUSES[s % HOUSES.length]} />;
    case 'hotel': {
      const [kind, view] = ROOMS[s % ROOMS.length];
      return <PhotoInterior kind={kind} view={view} />;
    }
    case 'fitness':
      return <PhotoStudio kind={STUDIOS[s % STUDIOS.length]} />;
    case 'yoga':
      return <PhotoStudio kind={CALM[s % CALM.length]} />;
    case 'salon':
      return <PhotoTreatment kind={s % 2 === 0 ? 'salon' : 'spa'} />;
    case 'practice':
      return <PhotoTreatment kind="practice" />;
    case 'law':
      return <PhotoOffice seed={s} />;
    case 'craft':
      return <PhotoWorkshop />;
    case 'travel':
      return s % 2 === 0 ? <PhotoCoast /> : <PhotoValley />;
    case 'software':
      return <SoftwareShot seed={s} />;
    case 'education':
      return <CourseCover seed={s} />;
    case 'events':
      return <StagePhoto seed={s} anchor="xMidYMin" />;
    case 'shop':
      return <PhotoShop seed={s} />;
    case 'agency':
      return <BrandBoard seed={s} />;
  }
}

/** "4 / 3" → [4, 3] */
function ratioOf(aspect: string): [number, number] | null {
  const m = aspect.match(/^\s*([\d.]+)\s*(?:\/\s*([\d.]+))?\s*$/);
  if (!m) return null;
  return [Number(m[1]), Number(m[2] ?? 1)];
}

/**
 * A placeholder picture for an industry.
 *
 *   <Placeholder industry="restaurant" seed={2} aspect="3 / 2" label="Lunch menu" showRatio />
 */
export function Placeholder({ industry = 'restaurant', seed = 0, aspect = '4 / 3', rounded = true, label, showRatio = false, ratioWidth = 1200, zoom = false, crop = 1, focus = '50% 50%', alt, labels, className }: PlaceholderProps) {
  const t = useTexts(TEXTS, labels);
  const text = alt ?? t[industry];
  // with aspect="auto" the size hint follows the rendered box
  const ref = useRef<HTMLDivElement>(null);
  const [box, setBox] = useState<[number, number] | null>(null);
  useEffect(() => {
    const el = ref.current;
    if (!el || !showRatio || aspect !== 'auto' || typeof ResizeObserver === 'undefined') return;
    const ro = new ResizeObserver(() => setBox(el.clientWidth && el.clientHeight ? [el.clientWidth, el.clientHeight] : null));
    ro.observe(el);
    return () => ro.disconnect();
  }, [showRatio, aspect]);
  const r = aspect === 'auto' ? box : ratioOf(aspect);
  const hint = r ? `${Math.round(ratioWidth)} × ${(aspect === 'auto' ? Math.round((ratioWidth * r[1]) / r[0] / 10) * 10 : Math.round((ratioWidth * r[1]) / r[0]))}` : null;
  return (
    <div
      ref={ref}
      role={text ? 'img' : undefined}
      aria-label={text || undefined}
      aria-hidden={text ? undefined : true}
      className={cn('group/ph relative isolate overflow-hidden bg-surface-2', rounded && 'rounded-xl border border-border', className)}
      style={aspect !== 'auto' ? { aspectRatio: aspect } : undefined}
    >
      <div aria-hidden data-clip-ok className={cn('absolute inset-0', zoom && 'transition-transform duration-700 ease-[var(--ease-kit)] group-hover/ph:scale-[1.045] motion-reduce:transition-none')}>
        <div className="absolute inset-0 [&>svg]:absolute [&>svg]:inset-0" style={crop !== 1 ? { transform: `scale(${Math.max(1, crop)})`, transformOrigin: focus } : undefined}>
          {art(industry, seed)}
        </div>
      </div>
      {showRatio && hint && (
        <span title={`${t.ratio} ${hint}`} className="absolute right-2.5 top-2.5 inline-flex items-center gap-1 rounded-md bg-[color-mix(in_oklab,var(--background)_78%,transparent)] px-1.5 py-1 text-[10.5px] font-medium leading-none tabular-nums text-foreground shadow-[var(--shadow-sm)] backdrop-blur">
          <svg viewBox="0 0 12 12" className="size-2.5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
            <path d="M1.5 4.5v-3h3M10.5 7.5v3h-3M1.5 1.5l3.5 3.5M10.5 10.5 7 7" />
          </svg>
          {hint}
        </span>
      )}
      {label && (
        <span className="absolute bottom-2.5 left-2.5 max-w-[calc(100%-1.25rem)] truncate rounded-md bg-[color-mix(in_oklab,var(--background)_82%,transparent)] px-2 py-1 text-[11.5px] font-medium leading-tight text-foreground shadow-[var(--shadow-sm)] backdrop-blur">
          {label}
        </span>
      )}
    </div>
  );
}

Frequent questions

How do I install Placeholder images?

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

Which variants does Placeholder images have?

5 variants, Hospitality, Property listing, Health and wellness, Business and studio and All industries.

Can I translate the texts of Placeholder images?

Yes. Placeholder images carries 18 texts of its own in English and German, such as “A dish on a restaurant table”, “Coffee in a café in morning light” and “A drink on the bar”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Placeholder images?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Placeholder images with aiw_components and install it with aiw_add @ai-website/placeholder.

Often searched

Jump to

Copied to the clipboard