Zum Inhalt springen
UI Kit
Accent

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

3 Variants

Variants

Zwei Kacheln

Zwei gleich große Aktionskacheln. Das Foto treibt beim Scrollen und zoomt beim Hover, die Textkarte hebt sich und der Sticker richtet sich auf. Die ganze Kachel ist der Link.

Im Studio öffnen

Mosaik

Eine hohe Kachel für die Hauptaktion, zwei kleine daneben. Auf dem Tablet geht die Hauptkachel über die ganze Breite, auf dem Telefon stehen alle drei untereinander.

Im Studio öffnen

Angebotsreihe

Angebotskarten mit Sticker und Enddatum in einer Reihe, die per Pfeil, Ziehen oder Wischen scrollt. Karten heben sich beim Hover, unter Desktop zeigt eine Fortschrittslinie, wo man ist.

Im Studio öffnen

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

Props

Die Schnittstelle PromoProps aus components/ui/promo.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    PromoVariant

    two-upmosaicoffer-rail

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode

    Optional heading above the tiles

  • descriptionoptional
    React.ReactNode
  • actionoptional
    PromoLink

    A link to all offers, top right on wide screens

  • itemsPflicht
    PromoItem[]

Eingebaute Texte

Diese Texte schreibt Aktionskacheln selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 3 Texte anzeigen
previousEnglisch Scroll backDeutsch Zurückblättern
nextEnglisch Scroll onDeutsch Weiterblättern
offersEnglisch OffersDeutsch 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
components/ui/promo.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert