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
Variants
Details
What Placeholder images brings along, read from the registry and the source.
- Category
- Placeholders and generators
- Area
- Visuals
- Variants
- 5
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/placeholder.tsx
- npm packages
- None
- Languages
- English and German, 18 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PlaceholderProps interface from components/ui/placeholder.tsx, with the comments from the code.
industryoptionalPlaceholderIndustryrestaurantcafebarreal-estatehotelfitnessyogasalonpracticelawcrafttravelsoftwareeducationeventsshopagency
seedoptionalnumberPicks another shot of the same industry
aspectoptionalstringCSS aspect ratio, default "4 / 3". Pass "auto" to size it with className.
roundedoptionalbooleanRounded corners and a hairline edge, default true
labeloptionalReact.ReactNodeAn optional caption chip in the lower left
showRatiooptionalbooleanA small size hint for builders in the upper right, e.g. 1200 × 900
ratioWidthoptionalnumberWidth the size hint is based on, default 1200
zoomoptionalbooleanZooms the picture a little on hover, default false
cropoptionalnumberCrops into the picture for a detail shot, 1 shows all of it (default), 2 shows a quarter
focusoptionalstringThe point the crop centers on, as CSS position, default "50% 50%"
altoptionalstringAlternative 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
| Key | English | German |
|---|---|---|
| restaurant | English A dish on a restaurant table | German Ein Gericht auf einem Restauranttisch |
| cafe | English Coffee in a café in morning light | German Kaffee in einem Café im Morgenlicht |
| bar | English A drink on the bar | German Ein Drink an der Bar |
| real-estate | English A house for sale | German Ein Haus zum Verkauf |
| hotel | English A hotel room with a view | German Ein Hotelzimmer mit Aussicht |
| fitness | English A training studio | German Ein Trainingsstudio |
| yoga | English A calm yoga studio | German Ein ruhiges Yogastudio |
| salon | English A salon with a mirror and a chair | German Ein Salon mit Spiegel und Stuhl |
| practice | English A bright treatment room | German Ein heller Behandlungsraum |
| law | English An office with books and a desk | German Ein Büro mit Büchern und Schreibtisch |
| craft | English A workshop bench with ceramics | German Eine Werkbank mit Keramik |
| travel | English A landscape for a trip | German Eine Landschaft für eine Reise |
| software | English A software dashboard | German Ein Software-Dashboard |
| education | English A course cover | German Ein Kurscover |
| events | English A conference stage | German Eine Konferenzbühne |
| shop | English A small boutique | German Eine kleine Boutique |
| agency | English A brand identity board | German Ein Markenboard |
| ratio | English Image size | German 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
'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.