Promo tiles
Campaign tiles for shops and offers. A sale, a new collection, a gift card or a subscription, each as one link with an image, a short line and a sticker.
One of 2 components in Banners
Variants
Details
What Promo tiles brings along, read from the registry and the source.
- Category
- Banners
- Area
- Marketing
- Variants
- 3
- Version
- 1.0.1 · since 1.0.0
- File
- components/ui/promo.tsx
- Brings along
- utilskiticonrevealmotionsection
- npm packages
- None
- Languages
- English and German, 3 texts of its own
- Studio
- All 3 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PromoProps interface from components/ui/promo.tsx, with the comments from the code.
variantoptionalPromoVarianttwo-upmosaicoffer-rail
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodeOptional heading above the tiles
descriptionoptionalReact.ReactNodeactionoptionalPromoLinkA link to all offers, top right on wide screens
itemsrequiredPromoItem[]
Built-in texts
Promo tiles writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 3 texts
| Key | English | German |
|---|---|---|
| previous | English Scroll back | German Zurückblättern |
| next | English Scroll on | German Weiterblättern |
| offers | English Offers | German Angebote |
Accessibility
Read from the source of the component.
- 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
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 6 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, 338 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { useScrollProgress } from '@/components/ui/motion';
import { Icon } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow, Section } from '@/components/ui/section';
/*
* Campaign tiles for shops and offers: a sale, a new collection, a gift
* card, a subscription. Each tile is one link with an image, a short line
* and a sticker. Images come in as nodes (`image`).
*
* two-up two equal tiles side by side
* mosaic one tall tile and two small ones
* offer-rail smaller offer cards in a row that scrolls, each with an end date
*/
export type PromoVariant = 'two-up' | 'mosaic' | 'offer-rail';
export interface PromoLink {
label: string;
href: string;
}
export interface PromoItem {
eyebrow?: string;
title: string;
text?: string;
action: PromoLink;
/** A photo or drawn motif, the tile shows its surface without one */
image?: React.ReactNode;
/** A round sticker, e.g. "-30 %" or "New" */
badge?: string;
/** offer-rail: when the offer ends, e.g. "Ends Sunday" */
ends?: string;
}
export interface PromoProps {
variant?: PromoVariant;
eyebrow?: React.ReactNode;
/** Optional heading above the tiles */
title?: React.ReactNode;
description?: React.ReactNode;
/** A link to all offers, top right on wide screens */
action?: PromoLink;
items: PromoItem[];
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: { previous: 'Scroll back', next: 'Scroll on', offers: 'Offers' },
de: { previous: 'Zurückblättern', next: 'Weiterblättern', offers: 'Angebote' },
};
type Texts = (typeof TEXTS)['en'];
const FOCUS = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
/* ------------------------------------------------------------------ */
/* Shared parts */
/* ------------------------------------------------------------------ */
function Head({ eyebrow, title, description, action, aside }: Pick<PromoProps, 'eyebrow' | 'title' | 'description' | 'action'> & { aside?: React.ReactNode }) {
if (!title && !action && !aside) return null;
return (
<div className="mb-10 flex flex-col gap-6 sm:mb-12 sm:flex-row sm:items-end sm:justify-between sm:gap-10">
{title ? (
<div className="max-w-[38rem]">
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && (
<p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{description}
</p>
)}
</div>
) : (
<span />
)}
{(action || aside) && (
<div className="flex shrink-0 items-center gap-4">
{action && (
<a href={action.href} className={cn('group/all inline-flex min-h-11 items-center gap-2 rounded-md text-[14.5px] font-medium text-foreground', FOCUS)}>
<Underline>{action.label}</Underline>
<Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/all:translate-x-1" />
</a>
)}
{aside}
</div>
)}
</div>
);
}
/** A line that draws under the text when its group is hovered */
function Underline({ children, group = 'all' }: { children: React.ReactNode; group?: 'all' | 'tile' }) {
return (
<span
className={cn(
'bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat pb-0.5 transition-[background-size] duration-500 ease-[var(--ease-kit)]',
group === 'all' ? 'group-hover/all:bg-[length:100%_1px]' : 'group-hover/tile:bg-[length:100%_1px] group-focus-within/tile:bg-[length:100%_1px]',
)}
>
{children}
</span>
);
}
/** The image drifts inside its frame while scrolling and zooms a little on hover */
function TileImage({ children, className }: { children: React.ReactNode; className?: string }) {
const ref = useScrollProgress<HTMLDivElement>();
return (
<div ref={ref} data-clip-ok aria-hidden className={cn('absolute inset-0 overflow-hidden', className)}>
<div className="absolute inset-x-0 -inset-y-[6%] motion-reduce:[transform:none]!" style={{ transform: 'translate3d(0, calc((0.5 - var(--kit-progress, 0.5)) * 8%), 0)' }}>
<div className="size-full transition-transform duration-[900ms] ease-[var(--ease-kit)] group-hover/tile:scale-[1.05] motion-reduce:transition-none [&>*]:size-full">{children}</div>
</div>
</div>
);
}
/** A round sticker that straightens up when the tile is hovered */
function Sticker({ children, size = 'md', className }: { children: React.ReactNode; size?: 'sm' | 'md' | 'lg'; className?: string }) {
return (
<span
className={cn(
'grid place-items-center rounded-full bg-primary text-center font-semibold leading-none tracking-[-0.02em] text-primary-foreground shadow-[var(--shadow-md)] transition-transform duration-500 ease-[var(--ease-kit)] [transform:rotate(-10deg)] group-hover/tile:[transform:rotate(0deg)_scale(1.06)] motion-reduce:transition-none',
size === 'lg' ? 'size-20 text-[18px] sm:size-24 sm:text-[22px]' : size === 'md' ? 'size-16 text-[15px] sm:size-20 sm:text-[18px]' : 'size-14 text-[13.5px]',
className,
)}
>
<span className="px-2">{children}</span>
</span>
);
}
/**
* One campaign tile: the whole surface is the link, the caption sits on a
* calm card so it reads on any photo, in light and dark.
*/
function Tile({ item, size = 'md', className, delay = 0 }: { item: PromoItem; size?: 'md' | 'lg'; className?: string; delay?: number }) {
return (
<Reveal effect="scale" delay={delay} className={cn('group/tile relative isolate overflow-hidden rounded-2xl border border-border bg-surface-2 shadow-[var(--highlight),var(--shadow-sm)] transition-shadow duration-500 hover:shadow-[var(--highlight),var(--shadow-lg)] has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-offset-2 has-[a:focus-visible]:outline-[var(--ring)]', className)}>
<TileImage>{item.image}</TileImage>
<a href={item.action.href} aria-label={`${item.title}, ${item.action.label}`} className="absolute inset-0 z-[5] rounded-2xl outline-none" />
{item.badge && (
<span aria-hidden className="pointer-events-none absolute right-4 top-4 z-10 sm:right-5 sm:top-5">
<Sticker size={size === 'lg' ? 'lg' : 'md'}>{item.badge}</Sticker>
</span>
)}
<div className={cn('pointer-events-none absolute inset-x-3 bottom-3 z-10 sm:inset-x-auto sm:bottom-4 sm:left-4', size === 'lg' ? 'sm:max-w-[25rem] lg:bottom-6 lg:left-6' : 'sm:max-w-[21rem]')}>
<div className="rounded-xl border border-border bg-background/88 p-4 shadow-[var(--shadow-md)] backdrop-blur-md transition-transform duration-500 ease-[var(--ease-kit)] group-hover/tile:-translate-y-1 motion-reduce:transition-none sm:p-5">
{item.eyebrow && <p className="text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{item.eyebrow}</p>}
<h3 className={cn('mt-1 text-balance font-semibold tracking-[-0.02em] text-foreground', size === 'lg' ? 'text-[22px] sm:text-[26px]' : 'text-[19px] sm:text-[21px]')}>{item.title}</h3>
{item.text && <p className="mt-1.5 text-pretty text-[14px] leading-relaxed text-muted-foreground">{item.text}</p>}
<span aria-hidden className="mt-3 inline-flex items-center gap-1.5 text-[14px] font-medium text-foreground">
<Underline group="tile">{item.action.label}</Underline>
<Icon name="arrow-right" size={14} className="transition-transform duration-300 group-hover/tile:translate-x-1" />
</span>
</div>
</div>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* two-up and mosaic */
/* ------------------------------------------------------------------ */
function TwoUp(p: PromoProps) {
return (
<Section className={p.className}>
<Head {...p} />
<div className="grid grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-2 lg:gap-6">
{p.items.slice(0, 2).map((item, i) => (
<Tile key={i} item={item} delay={i * 110} className="aspect-[4/5] sm:aspect-[5/6] lg:aspect-[6/5]" />
))}
</div>
</Section>
);
}
function Mosaic(p: PromoProps) {
const [first, ...rest] = p.items;
return (
<Section className={p.className}>
<Head {...p} />
<div className="grid grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-2 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:grid-rows-[repeat(2,minmax(0,17.5rem))] lg:gap-6">
{first && <Tile item={first} size="lg" className="aspect-[4/5] sm:col-span-2 sm:aspect-[16/9] lg:col-span-1 lg:row-span-2 lg:aspect-auto" />}
{rest.slice(0, 2).map((item, i) => (
<Tile key={i} item={item} delay={110 + i * 110} className="aspect-[4/3] sm:aspect-[5/6] lg:aspect-auto" />
))}
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* offer-rail */
/* ------------------------------------------------------------------ */
/** Arrows, mouse drag, progress and fades on the side where more follows */
function useRail() {
const ref = useRef<HTMLUListElement>(null);
const [pos, setPos] = useState({ start: true, end: true, progress: 0, view: 1 });
const drag = useRef<{ x: number; left: number; moved: boolean } | null>(null);
const [dragging, setDragging] = useState(false);
const measure = () => {
const el = ref.current;
if (!el) return;
const max = el.scrollWidth - el.clientWidth;
setPos({ start: el.scrollLeft <= 2, end: el.scrollLeft >= max - 2, progress: max > 0 ? el.scrollLeft / max : 0, view: el.scrollWidth ? el.clientWidth / el.scrollWidth : 1 });
};
useEffect(() => {
measure();
const ro = new ResizeObserver(measure);
if (ref.current) ro.observe(ref.current);
return () => ro.disconnect();
}, []);
const go = (dir: 1 | -1) => {
const el = ref.current;
const card = el?.querySelector('li');
if (!el || !card) return;
el.scrollBy({ left: dir * (card.getBoundingClientRect().width + 20), behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
};
const handlers = {
onScroll: measure,
onPointerDown: (e: React.PointerEvent<HTMLUListElement>) => {
if (e.pointerType !== 'mouse' || e.button !== 0 || !ref.current) return;
drag.current = { x: e.clientX, left: ref.current.scrollLeft, moved: false };
},
onPointerMove: (e: React.PointerEvent<HTMLUListElement>) => {
const d = drag.current;
const el = ref.current;
if (!d || !el) return;
const dx = e.clientX - d.x;
if (!d.moved && Math.abs(dx) > 5) {
d.moved = true;
setDragging(true);
el.setPointerCapture(e.pointerId);
}
if (d.moved) el.scrollLeft = d.left - dx;
},
onPointerUp: () => {
const moved = drag.current?.moved;
drag.current = null;
if (moved) requestAnimationFrame(() => setDragging(false));
},
onClickCapture: (e: React.MouseEvent) => {
if (dragging) e.preventDefault();
},
};
const overflow = !(pos.start && pos.end);
const fade = !overflow ? '' : pos.start ? 'kit-fade-end' : pos.end ? 'kit-fade-start' : 'kit-fade-x';
return { ref, pos, go, handlers, dragging, overflow, fade };
}
function OfferRail(p: PromoProps & { t: Texts }) {
const rail = useRail();
const thumb = Math.max(0.14, Math.min(1, rail.pos.view));
const arrow = (dir: 1 | -1) => (
<button
type="button"
onClick={() => rail.go(dir)}
disabled={dir < 0 ? rail.pos.start : rail.pos.end}
aria-label={dir < 0 ? p.t.previous : p.t.next}
className={cn('grid size-11 place-items-center rounded-full border border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] transition-[background-color,opacity,transform] duration-300 hover:bg-accent active:scale-95 disabled:pointer-events-none disabled:opacity-35', FOCUS)}
>
<Icon name={dir < 0 ? 'arrow-left' : 'arrow-right'} size={17} />
</button>
);
return (
<Section className={cn('overflow-x-clip', p.className)}>
<Head
{...p}
aside={
<div className={cn('flex gap-2 transition-opacity duration-300 max-sm:hidden', !rail.overflow && 'pointer-events-none opacity-0')} aria-hidden={!rail.overflow}>
{arrow(-1)}
{arrow(1)}
</div>
}
/>
<ul ref={rail.ref} {...rail.handlers} aria-label={p.t.offers} className={cn('kit-no-scrollbar -mx-6 flex snap-x snap-mandatory gap-5 overflow-x-auto scroll-px-6 px-6 pb-2 pt-1 lg:-mx-2 lg:scroll-px-2 lg:px-2', rail.dragging && 'cursor-grabbing snap-none', rail.fade)}>
{p.items.map((item, i) => (
<Reveal as="li" key={i} effect="rise" delay={Math.min(i, 3) * 90} className="w-[78%] shrink-0 snap-start sm:w-[calc((100%-1.25rem)/2.15)] lg:w-[calc((100%-3.75rem)/3.25)]">
<a href={item.action.href} draggable={false} className={cn('group/tile flex h-full flex-col rounded-2xl border border-border bg-card p-2.5 shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-lg)] motion-reduce:transition-none motion-reduce:hover:translate-y-0', FOCUS)}>
<span className="relative block aspect-[4/3] overflow-hidden rounded-xl bg-surface-2">
<TileImage>{item.image}</TileImage>
{item.badge && (
<span className="absolute left-3 top-3">
<Sticker size="sm">{item.badge}</Sticker>
</span>
)}
{item.ends && (
<span className="absolute bottom-3 left-3 inline-flex h-7 items-center gap-1.5 rounded-full border border-border bg-background/88 px-2.5 text-[12px] font-medium text-foreground backdrop-blur">
<Icon name="clock" size={13} className="text-muted-foreground" />
{item.ends}
</span>
)}
</span>
<span className="flex flex-1 flex-col px-2.5 pb-2.5 pt-4">
{item.eyebrow && <span className="text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{item.eyebrow}</span>}
<span className="mt-0.5 block text-balance text-[17px] font-semibold tracking-[-0.015em] text-foreground">{item.title}</span>
{item.text && <span className="mt-1 block text-pretty text-[14px] leading-relaxed text-muted-foreground">{item.text}</span>}
<span className="mt-auto inline-flex items-center gap-1.5 pt-4 text-[14px] font-medium text-foreground">
<Underline group="tile">{item.action.label}</Underline>
<Icon name="arrow-right" size={14} className="transition-transform duration-300 group-hover/tile:translate-x-1" />
</span>
</span>
</a>
</Reveal>
))}
</ul>
<div aria-hidden className={cn('relative mt-6 h-0.5 overflow-hidden rounded-full bg-border transition-opacity duration-300 lg:hidden', !rail.overflow && 'opacity-0')}>
<span className="absolute inset-y-0 left-0 rounded-full bg-foreground transition-transform duration-150" style={{ width: `${Math.round(thumb * 1000) / 10}%`, transform: `translateX(${Math.round(((rail.pos.progress * (1 - thumb)) / thumb) * 1000) / 10}%)` }} />
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* The block */
/* ------------------------------------------------------------------ */
/**
* Campaign tiles in three layouts. two-up: two equal tiles. mosaic: one tall
* tile and two small ones. offer-rail: offer cards with end dates in a row
* that scrolls.
*/
export function Promo(props: PromoProps) {
const { variant = 'two-up' } = props;
const t = useTexts(TEXTS, props.labels);
if (variant === 'mosaic') return <Mosaic {...props} />;
if (variant === 'offer-rail') return <OfferRail {...props} t={t} />;
return <TwoUp {...props} />;
}Frequent questions
How do I install Promo tiles?
With aiw add @ai-website/promo. The command puts components/ui/promo.tsx into your project, the code is yours from then on. It brings along icon, reveal, motion and section. It needs no further npm packages.
Which variants does Promo tiles have?
3 variants, Two tiles, Mosaic and Offer rail. You pick one with the variant prop, for example variant="mosaic".
Can I translate the texts of Promo tiles?
Yes. Promo tiles carries 3 texts of its own in English and German, such as “Scroll back”, “Scroll on” and “Offers”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Promo tiles?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Promo tiles with aiw_components and install it with aiw_add @ai-website/promo. Name the variant in your prompt, for example variant="two-up", and the assistant writes your content straight into the props.
Can I adjust Promo tiles without code?
Yes, in the Studio. All 3 variants are editable there, texts, lists and switches. The Studio writes the matching Promo code to copy.