Skip to content
UI Kit
Accent color

Shop promises

What a shop promises about shipping, returns, payment and service. A quiet strip under the header, cards with details, the free shipping bar and a money back guarantee with its seal.

One of 4 components in Delivery and stores

4 variants

Variants

Promise strip

Four promises in a quiet row with hairlines between them, rising one after another. On tablets two by two, on phones one at a time that slides on every four seconds, with arrows and a pause on touch.

Open in Studio

Promise cards

The promises as cards with a short text and a link to the details. Cards rise in turn, lift on hover with a soft light in the corner, and the icon tilts.

Open in Studio

Free shipping bar

How far it is to free shipping, for the cart, the mini cart or above the product grid. The bar fills when it scrolls in and glides with every change, a light runs along it, and reaching the threshold turns it green with a pop.

Open in Studio

Guarantee

One big promise with its seal. The ticks around the number draw in one after another when it scrolls in, the terms people want to read rise beside it, a link leads to the full policy.

Open in Studio

Details

What Shop promises brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/shop-benefits.tsx
Brings along
utilskiticonrevealmotionsectionproduct-image
npm packages
None
Languages
English and German, 11 texts of its own

Props

The ShopBenefitsProps interface from components/ui/shop-benefits.tsx, with the comments from the code.

  • variantoptional
    ShopBenefitsVariant

    stripcardsfree-shippingguarantee

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • itemsoptional
    ShopBenefit[]
  • thresholdoptional
    number

    free-shipping: the threshold and what is in the cart

  • subtotaloptional
    number
  • stepoptional
    number

    free-shipping: the step buttons in the demo add this much

  • daysoptional
    number

    guarantee: the number on the seal, e.g. 30

  • termsoptional
    string[]

    guarantee: the fine print as short lines

  • actionoptional
    { label: string; href: string }
  • currencyoptional
    string

Built-in texts

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

Show all 11 texts
benefitsEnglish Our promisesGerman Unsere Versprechen
moreEnglish DetailsGerman Details
toGoEnglish more for free shippingGerman bis zum kostenlosen Versand
reachedEnglish Free shipping unlockedGerman Versand ist kostenlos
inCartEnglish In your cartGerman Im Warenkorb
addEnglish AddGerman Hinzufügen
removeEnglish RemoveGerman Entfernen
daysEnglish daysGerman Tage
sealEnglish Money back guaranteeGerman Geld-zurück-Garantie
previousEnglish Previous promiseGerman Vorheriges Versprechen
nextEnglish Next promiseGerman Nächstes Versprechen

Accessibility

Read from the source of the component.

  • Sets aria-label, aria-live, aria-valuemax, aria-valuemin and aria-valuenow, so screen readers announce purpose and state.
  • Uses the role progressbar.
  • Announces changes through a live region without moving focus.
  • 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. 4 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, 347 lines
components/ui/shop-benefits.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { ShopMotion, Swap, TweenPrice, formatPrice } from '@/components/ui/product-image';

/*
 * What a shop promises: shipping, returns, payment, service. The small
 * strip that sits under the header or above the footer of almost every shop.
 *
 *   strip        a quiet row of four promises with icons, a rotating line on phones
 *   cards        the promises as cards with a short text and a link to the details
 *   free-shipping  the free shipping threshold as a bar that fills with the cart
 *   guarantee    one big promise with its seal and the fine print people want to see
 */

export type ShopBenefitsVariant = 'strip' | 'cards' | 'free-shipping' | 'guarantee';

export interface ShopBenefit {
  icon?: IconName;
  title: string;
  text?: string;
  href?: string;
}

export interface ShopBenefitsProps {
  variant?: ShopBenefitsVariant;
  eyebrow?: string;
  title?: string;
  description?: string;
  items?: ShopBenefit[];
  /** free-shipping: the threshold and what is in the cart */
  threshold?: number;
  subtotal?: number;
  /** free-shipping: the step buttons in the demo add this much */
  step?: number;
  /** guarantee: the number on the seal, e.g. 30 */
  days?: number;
  /** guarantee: the fine print as short lines */
  terms?: string[];
  action?: { label: string; href: string };
  currency?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    benefits: 'Our promises',
    more: 'Details',
    toGo: 'more for free shipping',
    reached: 'Free shipping unlocked',
    inCart: 'In your cart',
    add: 'Add',
    remove: 'Remove',
    days: 'days',
    seal: 'Money back guarantee',
    previous: 'Previous promise',
    next: 'Next promise',
  },
  de: {
    benefits: 'Unsere Versprechen',
    more: 'Details',
    toGo: 'bis zum kostenlosen Versand',
    reached: 'Versand ist kostenlos',
    inCart: 'Im Warenkorb',
    add: 'Hinzufügen',
    remove: 'Entfernen',
    days: 'Tage',
    seal: 'Geld-zurück-Garantie',
    previous: 'Vorheriges Versprechen',
    next: 'Nächstes Versprechen',
  },
};

type Texts = (typeof TEXTS)['en'];

/** "€60.00" reads as "€60" on a scale */
const whole = (price: string) => price.split(/[.,]00(?=\D|$)/).join('');

const FOCUS = 'outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const ACCENT_TEXT = 'text-[color-mix(in_oklab,var(--primary)_78%,var(--foreground))]';
const ACCENT_SOFT = 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)]';

/* ------------------------------------------------------------------ */
/* strip                                                               */
/* ------------------------------------------------------------------ */

function Strip(p: ShopBenefitsProps & { t: Texts }) {
  const { t } = p;
  const items = p.items ?? [];
  const [i, setI] = useState(0);
  const [paused, setPaused] = useState(false);
  // Phones show one promise at a time, the next one slides in every four seconds
  useEffect(() => {
    if (paused || items.length < 2 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const x = window.setInterval(() => setI((n) => (n + 1) % items.length), 4000);
    return () => window.clearInterval(x);
  }, [paused, items.length]);
  const cur = items[i];
  return (
    <section aria-label={t.benefits} className={cn('border-y border-border bg-surface', p.className)}>
      <ShopMotion />
      <div className="kit-container">
        {/* Tablet and desktop: all promises in a row, separated by hairlines */}
        <Stagger step={70} as="ul" className="hidden grid-cols-2 md:grid lg:grid-cols-4">
          {items.map((b, k) => (
            <li key={b.title} className={cn('flex items-center gap-3 py-4 md:px-5 lg:justify-center', k > 0 && 'lg:border-s lg:border-border', k % 2 === 1 && 'md:border-s md:border-border lg:border-s', k >= 2 && 'md:border-t md:border-border lg:border-t-0')}>
              <span className={cn('grid size-9 shrink-0 place-items-center rounded-full', ACCENT_SOFT, ACCENT_TEXT)}>
                <Icon name={b.icon ?? 'check'} size={17} />
              </span>
              <span className="min-w-0">
                {b.href ? (
                  <a href={b.href} className={cn('block truncate rounded-sm text-[14px] font-medium text-foreground hover:underline hover:decoration-border-strong hover:underline-offset-4', FOCUS)}>
                    {b.title}
                  </a>
                ) : (
                  <span className="block truncate text-[14px] font-medium text-foreground">{b.title}</span>
                )}
                {b.text && <span className="block truncate text-[12.5px] text-muted-foreground">{b.text}</span>}
              </span>
            </li>
          ))}
        </Stagger>
        {/* Phones: one promise, arrows and a quiet progress */}
        <div className="flex items-center gap-2 py-2 md:hidden" onPointerDown={() => setPaused(true)} onFocus={() => setPaused(true)}>
          <button type="button" onClick={() => setI((n) => (n - 1 + items.length) % items.length)} aria-label={t.previous} className={cn('grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground hover:text-foreground', FOCUS)}>
            <Icon name="chevron-left" size={16} />
          </button>
          <div className="relative min-w-0 flex-1 overflow-hidden text-center" aria-live={paused ? 'polite' : 'off'}>
            {cur && (
              <p key={i} className="flex animate-[kitshop-next_0.45s_var(--ease-kit)] items-center justify-center gap-2 text-[13.5px] font-medium text-foreground">
                <Icon name={cur.icon ?? 'check'} size={15} className={ACCENT_TEXT} />
                <span className="truncate">{cur.title}</span>
              </p>
            )}
          </div>
          <button type="button" onClick={() => setI((n) => (n + 1) % items.length)} aria-label={t.next} className={cn('grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground hover:text-foreground', FOCUS)}>
            <Icon name="chevron-right" size={16} />
          </button>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* cards                                                               */
/* ------------------------------------------------------------------ */

function Cards(p: ShopBenefitsProps & { t: Texts }) {
  const { t } = p;
  const items = p.items ?? [];
  return (
    <Section className={p.className}>
      <ShopMotion />
      {p.title && (
        <div className="mb-10 max-w-[40rem] sm:mb-12">
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{p.title}</h2>
          {p.description && <p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground">{p.description}</p>}
        </div>
      )}
      <Stagger step={80} as="ul" className={cn('grid grid-cols-1 gap-4 sm:grid-cols-2 lg:gap-5', items.length >= 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3')}>
        {items.map((b) => (
          <li key={b.title}>
            <article className={cn(CARD, 'group/b relative flex h-full flex-col overflow-hidden p-6 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')}>
              <span aria-hidden data-clip-ok className="pointer-events-none absolute -right-10 -top-10 size-32 rounded-full bg-[radial-gradient(circle,color-mix(in_oklab,var(--primary)_18%,transparent),transparent_70%)] opacity-0 transition-opacity duration-500 group-hover/b:opacity-100" />
              <span className={cn('relative grid size-12 place-items-center rounded-xl transition-transform duration-500 ease-[var(--ease-kit)] group-hover/b:-rotate-6 group-hover/b:scale-105', ACCENT_SOFT, ACCENT_TEXT)}>
                <Icon name={b.icon ?? 'check'} size={22} />
              </span>
              <h3 className="relative mt-5 text-[16.5px] font-semibold text-foreground">{b.title}</h3>
              {b.text && <p className="relative mt-1.5 text-pretty text-[14px] leading-relaxed text-muted-foreground">{b.text}</p>}
              {b.href && (
                <a href={b.href} className={cn('relative mt-auto inline-flex items-center gap-1.5 pt-5 text-[14px] font-medium text-foreground after:absolute after:inset-0 after:content-[""]', FOCUS)} aria-label={`${b.title}, ${t.more}`}>
                  {t.more}
                  <Icon name="arrow-right" size={14} className="transition-transform duration-300 group-hover/b:translate-x-1" />
                </a>
              )}
            </article>
          </li>
        ))}
      </Stagger>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* free-shipping                                                       */
/* ------------------------------------------------------------------ */

function FreeShipping(p: ShopBenefitsProps & { t: Texts }) {
  const { t, currency } = p;
  const locale = useLocale();
  const threshold = p.threshold ?? 60;
  const step = p.step ?? 14.9;
  const [sub, setSub] = useState(p.subtotal ?? 0);
  const [ref, inView] = useInView<HTMLDivElement>();
  const share = Math.min(1, sub / threshold);
  const left = Math.max(0, threshold - sub);
  const reached = left <= 0;
  const wasReached = useRef(reached);
  const [burst, setBurst] = useState(0);
  useEffect(() => {
    if (reached && !wasReached.current) setBurst((b) => b + 1);
    wasReached.current = reached;
  }, [reached]);
  return (
    <Section spacing="sm" className={p.className}>
      <ShopMotion />
      <div ref={ref} className={cn(CARD, 'mx-auto max-w-[40rem] p-5 sm:p-6')}>
        <div className="flex items-center gap-4">
          <span key={burst} className={cn('grid size-11 shrink-0 place-items-center rounded-full transition-colors duration-500', reached ? 'animate-[kitshop-pop_0.5s_var(--ease-kit)] bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-success-foreground' : cn(ACCENT_SOFT, ACCENT_TEXT))}>
            <Icon name={reached ? 'check' : 'truck'} size={19} />
          </span>
          <p className="min-w-0 flex-1 text-[15px] text-foreground" aria-live="polite">
            <Swap value={reached}>
              {reached ? (
                <span className="font-semibold text-success-foreground">{t.reached}</span>
              ) : (
                <span>
                  <span className="font-semibold tabular-nums">{formatPrice(locale, left, currency)}</span> {t.toGo}
                </span>
              )}
            </Swap>
          </p>
        </div>
        <div className="relative mt-5 h-2.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" role="progressbar" aria-valuemin={0} aria-valuemax={threshold} aria-valuenow={round(Math.min(sub, threshold))} aria-label={t.toGo}>
          <span className={cn('absolute inset-0 origin-left rounded-full transition-[transform,background-color] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', reached ? 'bg-success' : 'bg-primary')} style={{ transform: `scaleX(${inView ? round(share, 3) : 0})` }} />
          {/* A light that runs along the filled part */}
          <span aria-hidden className="absolute inset-y-0 left-0 w-full overflow-hidden" style={{ clipPath: `inset(0 ${round((1 - (inView ? share : 0)) * 100, 2)}% 0 0)` }}>
            <span className="absolute inset-0 animate-[kit-shine_2.4s_linear_infinite] bg-[linear-gradient(90deg,transparent_30%,rgb(255_255_255/0.4)_50%,transparent_70%)] bg-[length:200%_100%] motion-reduce:hidden" />
          </span>
        </div>
        <div className="mt-2 flex justify-between text-[12px] tabular-nums text-muted-foreground" aria-hidden>
          <span>{whole(formatPrice(locale, 0, currency))}</span>
          <span>{whole(formatPrice(locale, threshold, currency))}</span>
        </div>
        {p.description && <p className="mt-3 text-pretty text-[13px] text-muted-foreground">{p.description}</p>}
        <div className="mt-5 flex items-center justify-between gap-3 border-t border-border pt-4">
          <span className="text-[13.5px] text-muted-foreground">
            {t.inCart} <TweenPrice value={sub} currency={currency} className="font-semibold text-foreground" />
          </span>
          <span className="flex gap-2">
            <button type="button" onClick={() => setSub((s) => Math.max(0, round(s - step)))} disabled={sub <= 0} aria-label={t.remove} className={cn('grid size-11 place-items-center rounded-lg border border-border-strong text-foreground transition-colors hover:bg-accent disabled:opacity-40 sm:size-10', FOCUS)}>
              <Icon name="minus" size={15} />
            </button>
            <button type="button" onClick={() => setSub((s) => round(s + step))} aria-label={`${t.add} ${formatPrice(locale, step, currency)}`} className={cn('inline-flex h-11 items-center gap-1.5 rounded-lg border border-border-strong px-3 text-[13.5px] font-medium text-foreground transition-colors hover:bg-accent sm:h-10', FOCUS)}>
              <Icon name="plus" size={15} />
              <span className="tabular-nums">{formatPrice(locale, step, currency)}</span>
            </button>
          </span>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* guarantee                                                           */
/* ------------------------------------------------------------------ */

function Guarantee(p: ShopBenefitsProps & { t: Texts }) {
  const { t } = p;
  const [ref, inView] = useInView<HTMLDivElement>();
  const days = p.days ?? 30;
  const ticks = 36;
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className={cn(CARD, 'grid grid-cols-1 items-center gap-8 overflow-hidden p-6 sm:p-10 md:grid-cols-[auto_minmax(0,40rem)] md:justify-center md:gap-14 lg:py-14')}>
        {/* The seal: a ring of ticks that draw in, the number counts on top */}
        <div ref={ref} className="relative mx-auto grid size-48 place-items-center sm:size-56" aria-hidden>
          <svg viewBox="0 0 200 200" className="absolute inset-0 size-full">
            {Array.from({ length: ticks }, (_, k) => {
              const a = (k / ticks) * Math.PI * 2 - Math.PI / 2;
              const r1 = 88;
              const r2 = k % 3 === 0 ? 76 : 80;
              return (
                <line
                  key={k}
                  x1={round(100 + Math.cos(a) * r1)}
                  y1={round(100 + Math.sin(a) * r1)}
                  x2={round(100 + Math.cos(a) * r2)}
                  y2={round(100 + Math.sin(a) * r2)}
                  strokeWidth={k % 3 === 0 ? 2.4 : 1.4}
                  strokeLinecap="round"
                  className={cn('stroke-primary transition-opacity duration-300 motion-reduce:transition-none', inView ? 'opacity-100' : 'opacity-0')}
                  style={{ transitionDelay: inView ? `${k * 22}ms` : '0ms' }}
                />
              );
            })}
            <circle cx="100" cy="100" r="66" className="fill-[color-mix(in_oklab,var(--primary)_10%,var(--card))] stroke-[color-mix(in_oklab,var(--primary)_35%,transparent)]" strokeWidth="1" />
          </svg>
          <span className="relative text-center">
            <span className="block text-[56px] font-semibold leading-none tracking-[-0.05em] tabular-nums text-foreground">{days}</span>
            <span className="mt-1 block text-[13px] font-medium uppercase tracking-[0.14em] text-muted-foreground">{t.days}</span>
          </span>
        </div>
        <div>
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h2 className="text-balance text-h2 font-semibold text-foreground">{p.title ?? t.seal}</h2>
          {p.description && <p className="mt-4 max-w-[36rem] text-pretty text-lead text-muted-foreground">{p.description}</p>}
          {p.terms?.length ? (
            <Stagger step={70} as="ul" className="mt-6 grid grid-cols-1 gap-2.5 sm:grid-cols-2">
              {p.terms.map((term) => (
                <li key={term} className="flex items-start gap-2.5 text-[14px] leading-snug text-foreground">
                  <Icon name="check-circle" size={16} className="mt-px shrink-0 text-success-foreground" />
                  {term}
                </li>
              ))}
            </Stagger>
          ) : null}
          {p.action && (
            <a href={p.action.href} className={cn('group/a mt-7 inline-flex min-h-11 items-center gap-2 rounded-md text-[14.5px] font-medium text-foreground', FOCUS)}>
              {p.action.label}
              <Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/a:translate-x-1" />
            </a>
          )}
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* The block                                                           */
/* ------------------------------------------------------------------ */

/**
 * The promises of a shop in four layouts. strip: a quiet row under the
 * header. cards: promises with text and details. free-shipping: the
 * threshold as a bar. guarantee: one promise with its seal and terms.
 */
export function ShopBenefits(props: ShopBenefitsProps) {
  const { variant = 'strip' } = props;
  const t = useTexts(TEXTS, props.labels);
  if (variant === 'cards') return <Cards {...props} t={t} />;
  if (variant === 'free-shipping') return <FreeShipping {...props} t={t} />;
  if (variant === 'guarantee') return <Guarantee {...props} t={t} />;
  return <Strip {...props} t={t} />;
}

Frequent questions

How do I install Shop promises?

With aiw add @ai-website/shop-benefits. The command puts components/ui/shop-benefits.tsx into your project, the code is yours from then on. It brings along icon, reveal, motion, section and product-image. It needs no further npm packages.

Which variants does Shop promises have?

4 variants, Promise strip, Promise cards, Free shipping bar and Guarantee. You pick one with the variant prop, for example variant="cards".

Can I translate the texts of Shop promises?

Yes. Shop promises carries 11 texts of its own in English and German, such as “Our promises”, “Details” and “more for free shipping”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Shop promises?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Shop promises with aiw_components and install it with aiw_add @ai-website/shop-benefits. Name the variant in your prompt, for example variant="strip", and the assistant writes your content straight into the props.

Can I adjust Shop promises without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching ShopBenefits code to copy.

Often searched

Jump to

Copied to the clipboard