Aktionskacheln
Aktionskacheln für Shops und Angebote. Ein Sale, eine neue Kollektion, ein Gutschein oder ein Abo, jeweils als ein Link mit Bild, kurzer Zeile und Sticker.
Eine von 2 Components in Banners
Variants
Details
Was Aktionskacheln mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Banners
- Bereich
- Marketing
- Variants
- 3
- Version
- 1.0.1 · seit 1.0.0
- Datei
- components/ui/promo.tsx
- Bringt mit
- utilskiticonrevealmotionsection
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 3 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle PromoProps aus components/ui/promo.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalPromoVarianttwo-upmosaicoffer-rail
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodeOptional heading above the tiles
descriptionoptionalReact.ReactNodeactionoptionalPromoLinkA link to all offers, top right on wide screens
itemsPflichtPromoItem[]
Eingebaute Texte
Diese Texte schreibt Aktionskacheln selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 3 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| previous | Englisch Scroll back | Deutsch Zurückblättern |
| next | Englisch Scroll on | Deutsch Weiterblättern |
| offers | Englisch Offers | Deutsch Angebote |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- 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
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 6 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 338 Zeilen
'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} />;
}Häufige Fragen
Wie installiere ich Aktionskacheln?
Mit aiw add @ai-website/promo. Der Befehl legt die Datei components/ui/promo.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, reveal, motion und section. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Aktionskacheln?
3 Varianten, Zwei Kacheln, Mosaik und Angebotsreihe. Du wählst sie mit der Prop variant, zum Beispiel variant="mosaic".
Kann ich die Texte von Aktionskacheln übersetzen?
Ja. Aktionskacheln bringt 3 eigene Texte auf Deutsch und Englisch mit, etwa „Zurückblättern“, „Weiterblättern“ und „Angebote“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Aktionskacheln einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Aktionskacheln mit aiw_components und installieren sie mit aiw_add @ai-website/promo. Nenn im Prompt die Variante, etwa variant="two-up", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Aktionskacheln ohne Code anpassen?
Ja, im Studio. Alle 3 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Promo-Code zum Kopieren.