Skip to content
UI Kit
Accent color

Product questions

Questions and answers on a product page. A searchable list with shop and buyer answers, the most asked as an accordion, the question form with similar questions and questions sorted into topics.

One of 2 components in Reviews and questions

4 variants

Variants

Questions and answers

Search over questions and answers with the search words lit up. Every question has a "same question" vote, the shop answer sits on a tinted surface, more answers open with a soft height and "Helpful" counts along.

Open in Studio

Most asked

The five most asked questions as an accordion beside a heading. Rows rise one after another, the plus turns into a minus, the answer opens with a soft height and names who answered.

Open in Studio

Ask a question

The question form. While typing, questions that were already answered appear with the matching words lit up and open in place. Missing text or email is marked and focused, sending ends in a confirmation that repeats the question.

Open in Studio

By topic

Questions sorted into topics with counts. On desktop a topic rail beside the cards, on phones a row of chips that scrolls. Picking a topic lets the cards run in again.

Open in Studio

Details

What Product questions brings along, read from the registry and the source.

Variants
4
Version
1.0.1 · since 1.1.0
File
components/ui/product-qa.tsx
Brings along
utilskitbuttoniconrevealmotionsectionproduct-image
npm packages
None
Languages
English and German, 28 texts of its own

Props

The ProductQaProps interface from components/ui/product-qa.tsx, with the comments from the code.

  • variantoptional
    ProductQaVariant

    listcompactaskby-topic

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • questionsoptional
    QaQuestion[]
  • topicsoptional
    { id: string; label: string }[]

    by-topic: the topics in order, with an icon name per topic

  • productoptional
    { name: string; image?: React.ReactNode }

    ask: the product the question is about

  • actionoptional
    { label: string; href: string }

    compact: link to all questions

  • answerTimeoptional
    string

    ask: answer time promise, e.g. "within one business day"

  • defaultQuestionoptional
    string

    ask: text already in the field, e.g. from a search without results

  • onAskoptional
    (question: string, email: string) => void
  • linksoptional
    { ask?: string }

    Where "Ask a question" goes, the ask form on the same page by default: #ask

Built-in texts

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

Show all 28 texts
searchEnglish Search questionsGerman Fragen durchsuchen
searchPlaceholderEnglish Search questions and answersGerman Fragen und Antworten durchsuchen
questionsEnglish questionsGerman Fragen
questionEnglish questionGerman Frage
answersEnglish answersGerman Antworten
answerEnglish answerGerman Antwort
fromShopEnglish Answer from the shopGerman Antwort vom Shop
buyerEnglish Verified buyerGerman Verifizierter Kauf
helpfulEnglish HelpfulGerman Hilfreich
showMoreEnglish Show more answersGerman Weitere Antworten zeigen
showLessEnglish Show fewer answersGerman Weniger Antworten zeigen
noMatchEnglish No question matches yet. Ask it, we answer within a day.German Dazu gibt es noch keine Frage. Stell sie, wir antworten innerhalb eines Tages.
askEnglish Ask a questionGerman Frage stellen
askLabelEnglish Your questionGerman Deine Frage
askPlaceholderEnglish e.g. Is it safe in the dishwasher?German z. B. Darf er in die Spülmaschine?
similarEnglish Maybe already answeredGerman Vielleicht schon beantwortet
emailEnglish Email for the answerGerman E-Mail für die Antwort
emailHintEnglish We send the answer here. Your email is never shown.German Wir schicken die Antwort hierhin. Deine E-Mail wird nie gezeigt.
sendEnglish Send questionGerman Frage senden
sendingEnglish SendingGerman Wird gesendet
thanksEnglish Question sentGerman Frage gesendet
thanksTextEnglish We answerGerman Wir antworten
askAnotherEnglish Ask another questionGerman Weitere Frage stellen
sameQuestionEnglish Same questionGerman Gleiche Frage
allTopicsEnglish All topicsGerman Alle Themen
askedEnglish askedGerman gefragt
tooShortEnglish Write your question in a few more words.German Schreib deine Frage in ein paar Worten mehr.
emailErrorEnglish Enter your email so the answer can reach you.German Gib deine E-Mail ein, damit die Antwort ankommt.

Accessibility

Read from the source of the component.

  • Sets aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-live and aria-pressed, so screen readers announce purpose and state.
  • Uses the roles alert, region and status.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • 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. 2 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, 612 lines
components/ui/product-qa.tsx
'use client';

import { useId, useMemo, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } 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, formatNumber } from '@/components/ui/product-image';

/*
 * Questions and answers on a product page: what people asked, answered by
 * the shop or by other customers.
 *
 *   list      search over the questions, answers with votes, the shop answer marked
 *   compact   the five most asked as an accordion, with a link to all
 *   ask       ask a question, with similar questions shown while typing
 *   by-topic  questions sorted into topics, a topic rail on desktop and chips on phones
 */

export type ProductQaVariant = 'list' | 'compact' | 'ask' | 'by-topic';

export interface QaAnswer {
  author: string;
  /** shop: from the shop team, buyer: a verified buyer */
  role?: 'shop' | 'buyer';
  /** e.g. "Product team" */
  title?: string;
  date: string;
  body: string;
  helpful?: number;
}

export interface QaQuestion {
  id: string;
  question: string;
  author?: string;
  date?: string;
  topic?: string;
  answers: QaAnswer[];
  /** How many people asked the same */
  votes?: number;
}

export interface ProductQaProps {
  variant?: ProductQaVariant;
  eyebrow?: string;
  title?: string;
  description?: string;
  questions?: QaQuestion[];
  /** by-topic: the topics in order, with an icon name per topic */
  topics?: { id: string; label: string }[];
  /** ask: the product the question is about */
  product?: { name: string; image?: React.ReactNode };
  /** compact: link to all questions */
  action?: { label: string; href: string };
  /** ask: answer time promise, e.g. "within one business day" */
  answerTime?: string;
  /** ask: text already in the field, e.g. from a search without results */
  defaultQuestion?: string;
  onAsk?: (question: string, email: string) => void;
  /** Where "Ask a question" goes, the ask form on the same page by default: #ask */
  links?: { ask?: string };
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    search: 'Search questions',
    searchPlaceholder: 'Search questions and answers',
    questions: 'questions',
    question: 'question',
    answers: 'answers',
    answer: 'answer',
    fromShop: 'Answer from the shop',
    buyer: 'Verified buyer',
    helpful: 'Helpful',
    showMore: 'Show more answers',
    showLess: 'Show fewer answers',
    noMatch: 'No question matches yet. Ask it, we answer within a day.',
    ask: 'Ask a question',
    askLabel: 'Your question',
    askPlaceholder: 'e.g. Is it safe in the dishwasher?',
    similar: 'Maybe already answered',
    email: 'Email for the answer',
    emailHint: 'We send the answer here. Your email is never shown.',
    send: 'Send question',
    sending: 'Sending',
    thanks: 'Question sent',
    thanksText: 'We answer',
    askAnother: 'Ask another question',
    sameQuestion: 'Same question',
    allTopics: 'All topics',
    asked: 'asked',
    tooShort: 'Write your question in a few more words.',
    emailError: 'Enter your email so the answer can reach you.',
  },
  de: {
    search: 'Fragen durchsuchen',
    searchPlaceholder: 'Fragen und Antworten durchsuchen',
    questions: 'Fragen',
    question: 'Frage',
    answers: 'Antworten',
    answer: 'Antwort',
    fromShop: 'Antwort vom Shop',
    buyer: 'Verifizierter Kauf',
    helpful: 'Hilfreich',
    showMore: 'Weitere Antworten zeigen',
    showLess: 'Weniger Antworten zeigen',
    noMatch: 'Dazu gibt es noch keine Frage. Stell sie, wir antworten innerhalb eines Tages.',
    ask: 'Frage stellen',
    askLabel: 'Deine Frage',
    askPlaceholder: 'z. B. Darf er in die Spülmaschine?',
    similar: 'Vielleicht schon beantwortet',
    email: 'E-Mail für die Antwort',
    emailHint: 'Wir schicken die Antwort hierhin. Deine E-Mail wird nie gezeigt.',
    send: 'Frage senden',
    sending: 'Wird gesendet',
    thanks: 'Frage gesendet',
    thanksText: 'Wir antworten',
    askAnother: 'Weitere Frage stellen',
    sameQuestion: 'Gleiche Frage',
    allTopics: 'Alle Themen',
    asked: 'gefragt',
    tooShort: 'Schreib deine Frage in ein paar Worten mehr.',
    emailError: 'Gib deine E-Mail ein, damit die Antwort ankommt.',
  },
};

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

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 FIELD = 'w-full rounded-lg border border-input bg-background px-3.5 text-[15px] text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground hover:border-border-strong focus:border-border-strong focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]';
const ACCENT_TEXT = 'text-[color-mix(in_oklab,var(--primary)_78%,var(--foreground))]';
const MARK = 'rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_24%,transparent)] text-foreground [box-shadow:0_0_0_2px_color-mix(in_oklab,var(--primary)_24%,transparent)]';

function Head({ eyebrow, title, description, aside }: { eyebrow?: string; title?: string; description?: string; aside?: React.ReactNode }) {
  if (!title) return null;
  return (
    <div className="mb-10 flex flex-col gap-6 sm:mb-12 sm:flex-row sm:items-end sm:justify-between">
      <div className="max-w-[40rem]">
        {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>
      {aside}
    </div>
  );
}

function highlight(text: string, q: string, min = 3): React.ReactNode {
  const words = q.trim().split(/[\s?!.,]+/).filter((w) => w.length >= min);
  if (!words.length) return text;
  const re = new RegExp(`(${words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'gi');
  return text.split(re).map((p, i) => (i % 2 ? <mark key={i} className={MARK}>{p}</mark> : p));
}

/** One answer: shop answers get a tinted surface and a badge, a helpful toggle with a count */
function Answer({ a, t, query = '' }: { a: QaAnswer; t: Texts; query?: string }) {
  const locale = useLocale();
  const [on, setOn] = useState(false);
  const shop = a.role === 'shop';
  return (
    <div className={cn('rounded-xl p-4', shop ? 'border border-[color-mix(in_oklab,var(--primary)_25%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]' : 'bg-surface')}>
      <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[12.5px]">
        <span className="font-medium text-foreground">{a.author}</span>
        {shop ? (
          <span className={cn('inline-flex items-center gap-1 rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-2 py-0.5 font-medium', ACCENT_TEXT)}>
            <Icon name="check-circle" size={12} />
            {a.title ?? t.fromShop}
          </span>
        ) : a.role === 'buyer' ? (
          <span className="inline-flex items-center gap-1 text-success-foreground">
            <Icon name="check" size={12} />
            {t.buyer}
          </span>
        ) : null}
        <span className="text-muted-foreground">· {a.date}</span>
      </div>
      <p className="mt-2 text-pretty text-[14.5px] leading-relaxed text-foreground">{highlight(a.body, query)}</p>
      <button
        type="button"
        aria-pressed={on}
        onClick={() => setOn(!on)}
        className={cn('relative mt-3 inline-flex h-9 items-center after:absolute after:-inset-1 after:content-[""] gap-1.5 rounded-md px-2 -ms-2 text-[12.5px] transition-colors', on ? 'text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground', FOCUS)}
      >
        <Icon name="thumbs-up" size={14} className={cn(on && 'animate-[kitshop-pop_0.4s_var(--ease-kit)] text-primary')} />
        {t.helpful}
        {(a.helpful ?? 0) + (on ? 1 : 0) > 0 && (
          <Swap value={(a.helpful ?? 0) + (on ? 1 : 0)}>
            <span className="tabular-nums">({formatNumber(locale, (a.helpful ?? 0) + (on ? 1 : 0))})</span>
          </Swap>
        )}
      </button>
    </div>
  );
}

/** "Same question" with a count: a vote that the question matters */
function SameVote({ q, t }: { q: QaQuestion; t: Texts }) {
  const locale = useLocale();
  const [on, setOn] = useState(false);
  const n = (q.votes ?? 0) + (on ? 1 : 0);
  return (
    <button
      type="button"
      aria-pressed={on}
      onClick={() => setOn(!on)}
      className={cn('flex w-11 shrink-0 flex-col items-center self-start rounded-xl border py-2 transition-colors sm:w-14', on ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-foreground hover:bg-accent', FOCUS)}
      aria-label={`${t.sameQuestion}, ${formatNumber(locale, n)}`}
    >
      <Icon name="chevron-up" size={16} className={cn('transition-transform duration-300', on && '-translate-y-0.5')} />
      <Swap value={n}>
        <span className="text-[14px] font-semibold tabular-nums">{formatNumber(locale, n)}</span>
      </Swap>
    </button>
  );
}

/* ------------------------------------------------------------------ */
/* list                                                                */
/* ------------------------------------------------------------------ */

function List(p: ProductQaProps & { t: Texts }) {
  const { t } = p;
  const locale = useLocale();
  const [query, setQuery] = useState('');
  const [open, setOpen] = useState<Record<string, boolean>>({});
  const id = useId();
  const q = query.trim().toLowerCase();
  const all = p.questions ?? [];
  const shown = useMemo(() => (q ? all.filter((x) => `${x.question} ${x.answers.map((a) => a.body).join(' ')}`.toLowerCase().includes(q)) : all), [all, q]);
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head
        eyebrow={p.eyebrow}
        title={p.title}
        description={p.description}
        aside={
          <Button href={p.links?.ask ?? '#ask'} variant="outline" iconLeft="help-circle">
            {t.ask}
          </Button>
        }
      />
      <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
        <label htmlFor={id} className="relative block sm:w-[26rem]">
          <span className="sr-only">{t.search}</span>
          <Icon name="search" size={16} className="pointer-events-none absolute start-3.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
          <input id={id} type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t.searchPlaceholder} className={cn(FIELD, 'h-11 ps-10')} />
        </label>
        <p className="text-[13.5px] text-muted-foreground" aria-live="polite">
          <span className="font-medium tabular-nums text-foreground">{formatNumber(locale, shown.length)}</span> {shown.length === 1 ? t.question : t.questions}
        </p>
      </div>
      {shown.length ? (
        <ul key={q} className="mt-6 divide-y divide-border border-y border-border">
          {shown.map((x, i) => {
            const more = x.answers.length > 1;
            const isOpen = !!open[x.id];
            return (
              <li key={x.id} className="flex gap-4 py-7 animate-[kitshop-in_0.45s_var(--ease-kit)_both] sm:gap-6" style={{ animationDelay: `${Math.min(i, 6) * 60}ms` }}>
                <SameVote q={x} t={t} />
                <div className="min-w-0 flex-1">
                  <h3 className="text-pretty text-[16px] font-semibold leading-snug text-foreground">{highlight(x.question, query)}</h3>
                  {(x.author || x.date) && (
                    <p className="mt-1 text-[12.5px] text-muted-foreground">
                      {[x.author, x.date].filter(Boolean).join(' · ')} · {formatNumber(locale, x.answers.length)} {x.answers.length === 1 ? t.answer : t.answers}
                    </p>
                  )}
                  <div className="mt-4">
                    {x.answers[0] && <Answer a={x.answers[0]} t={t} query={query} />}
                    {more && (
                      <div className={cn('grid transition-[grid-template-rows,opacity,visibility] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', isOpen ? 'grid-rows-[1fr] opacity-100' : 'invisible grid-rows-[0fr] opacity-0')}>
                        <div className="space-y-3 overflow-hidden pt-3" inert={!isOpen}>
                          {x.answers.slice(1).map((a, k) => (
                            <Answer key={k} a={a} t={t} query={query} />
                          ))}
                        </div>
                      </div>
                    )}
                  </div>
                  {more && (
                    <button type="button" aria-expanded={isOpen} onClick={() => setOpen((o) => ({ ...o, [x.id]: !isOpen }))} className={cn('mt-1 inline-flex min-h-11 items-center gap-1.5 rounded-md text-[13.5px] font-medium text-foreground', FOCUS)}>
                      {isOpen ? t.showLess : `${t.showMore} (${formatNumber(locale, x.answers.length - 1)})`}
                      <Icon name="chevron-down" size={15} className={cn('transition-transform duration-300', isOpen && 'rotate-180')} />
                    </button>
                  )}
                </div>
              </li>
            );
          })}
        </ul>
      ) : (
        <div className="mt-6 flex flex-col items-center gap-4 rounded-2xl border border-dashed border-border-strong p-10 text-center">
          <p className="max-w-[26rem] text-pretty text-[14px] text-muted-foreground">{t.noMatch}</p>
          <Button href={p.links?.ask ?? '#ask'} variant="outline" iconLeft="help-circle">
            {t.ask}
          </Button>
        </div>
      )}
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* compact                                                             */
/* ------------------------------------------------------------------ */

function Compact(p: ProductQaProps & { t: Texts }) {
  const { t } = p;
  const [open, setOpen] = useState<string | null>((p.questions ?? [])[0]?.id ?? null);
  const id = useId();
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:gap-16">
        <div>
          {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>}
          {p.action && (
            <a href={p.action.href} className={cn('group/all mt-6 inline-flex min-h-11 items-center gap-2 text-[14.5px] font-medium text-foreground max-lg:hidden', FOCUS)}>
              {p.action.label}
              <Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/all:translate-x-1" />
            </a>
          )}
        </div>
        <div>
          <Stagger step={60} as="ul" className="divide-y divide-border border-y border-border">
            {(p.questions ?? []).map((x) => {
              const on = open === x.id;
              const a = x.answers[0];
              return (
                <li key={x.id}>
                  <h3>
                    <button
                      type="button"
                      id={`${id}-${x.id}`}
                      aria-expanded={on}
                      aria-controls={`${id}-${x.id}-p`}
                      onClick={() => setOpen(on ? null : x.id)}
                      className={cn('group/q flex w-full items-start gap-4 py-5 text-left', FOCUS)}
                    >
                      <span className={cn('mt-0.5 grid size-7 shrink-0 place-items-center rounded-full text-[12.5px] font-semibold transition-colors duration-300', on ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground group-hover/q:text-foreground')} aria-hidden>
                        ?
                      </span>
                      <span className="flex-1 text-pretty text-[15.5px] font-medium leading-snug text-foreground">{x.question}</span>
                      <span className="relative mt-1 size-4 shrink-0" aria-hidden>
                        <span className="absolute left-0 top-1/2 h-[1.5px] w-4 -translate-y-1/2 rounded-full bg-foreground" />
                        <span className={cn('absolute left-1/2 top-0 h-4 w-[1.5px] -translate-x-1/2 rounded-full bg-foreground transition-transform duration-300 ease-[var(--ease-kit)]', on && 'scale-y-0')} />
                      </span>
                    </button>
                  </h3>
                  <div id={`${id}-${x.id}-p`} role="region" aria-labelledby={`${id}-${x.id}`} className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
                    <div className="overflow-hidden" inert={!on}>
                      {a && (
                        <div className="pb-5 ps-11">
                          <p className="text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{a.body}</p>
                          <p className="mt-2 text-[12.5px] text-subtle-foreground">
                            {a.author}
                            {a.role === 'shop' && <span className={cn('ms-1.5 font-medium', ACCENT_TEXT)}>· {a.title ?? t.fromShop}</span>}
                          </p>
                        </div>
                      )}
                    </div>
                  </div>
                </li>
              );
            })}
          </Stagger>
          {p.action && (
            <a href={p.action.href} className={cn('group/all mt-5 inline-flex min-h-11 items-center gap-2 text-[14.5px] font-medium text-foreground lg:hidden', FOCUS)}>
              {p.action.label}
              <Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/all:translate-x-1" />
            </a>
          )}
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* ask                                                                 */
/* ------------------------------------------------------------------ */

function Ask(p: ProductQaProps & { t: Texts }) {
  const { t } = p;
  const [text, setText] = useState(p.defaultQuestion ?? '');
  const [email, setEmail] = useState('');
  const [state, setState] = useState<'idle' | 'sending' | 'done'>('idle');
  const [tried, setTried] = useState(false);
  const [peek, setPeek] = useState<string | null>(null);
  const id = useId();
  // Only longer words count, so "does" or "with" never make a question look similar
  const words = text.toLowerCase().split(/[\s?!.,]+/).filter((w) => w.length >= 5);
  const similar = (p.questions ?? [])
    .map((x) => ({ x, score: words.filter((w) => x.question.toLowerCase().includes(w)).length }))
    .filter((s) => s.score > 0)
    .sort((a, b) => b.score - a.score)
    .slice(0, 3)
    .map((s) => s.x);
  const shortText = text.trim().length < 12;
  const badEmail = !/^\S+@\S+\.\S+$/.test(email);
  const submit = (e: React.FormEvent) => {
    e.preventDefault();
    setTried(true);
    if (shortText || badEmail) {
      document.getElementById(shortText ? `${id}-q` : `${id}-e`)?.focus();
      return;
    }
    setState('sending');
    p.onAsk?.(text, email);
    window.setTimeout(() => setState('done'), 800);
  };
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div id="ask" className="mx-auto max-w-[44rem]">
        <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
        {state === 'done' ? (
          <div className={cn(CARD, 'animate-[kitshop-zoom_0.45s_var(--ease-kit)] p-6 text-center sm:p-8')} role="status">
            <span className="mx-auto grid size-14 animate-[kitshop-pop_0.5s_var(--ease-kit)] place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-success-foreground">
              <Icon name="send" size={22} />
            </span>
            <h3 className="mt-5 text-[20px] font-semibold text-foreground">{t.thanks}</h3>
            <p className="mx-auto mt-2 max-w-[28rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">
              {t.thanksText} {p.answerTime}. „{text.trim()}“
            </p>
            <Button variant="outline" className="mt-6" onClick={() => { setState('idle'); setText(''); setTried(false); }}>
              {t.askAnother}
            </Button>
          </div>
        ) : (
          <form noValidate onSubmit={submit} className={cn(CARD, 'p-5 sm:p-7')}>
            {p.product && (
              <div className="mb-6 flex items-center gap-3 border-b border-border pb-5">
                {p.product.image && <span className="size-12 shrink-0 overflow-hidden rounded-lg bg-surface-2 [&>*]:size-full">{p.product.image}</span>}
                <p className="text-[15px] font-medium text-foreground">{p.product.name}</p>
              </div>
            )}
            <label htmlFor={`${id}-q`} className="mb-2 block text-[14px] font-medium text-foreground">
              {t.askLabel}
            </label>
            <textarea
              id={`${id}-q`}
              value={text}
              onChange={(e) => setText(e.target.value.slice(0, 300))}
              rows={3}
              placeholder={t.askPlaceholder}
              aria-invalid={(tried && shortText) || undefined}
              className={cn(FIELD, 'resize-none py-3 leading-relaxed', tried && shortText && 'border-destructive')}
            />
            {tried && shortText && (
              <p role="alert" className="mt-1.5 text-[13px] text-destructive">
                {t.tooShort}
              </p>
            )}
            {similar.length > 0 && (
              <div className="mt-4 animate-[kitshop-in_0.35s_var(--ease-kit)] rounded-xl border border-border bg-surface p-3" aria-live="polite">
                <p className="flex items-center gap-1.5 px-1 text-[12.5px] font-medium text-muted-foreground">
                  <Icon name="lightbulb" size={14} />
                  {t.similar}
                </p>
                <ul className="mt-2 space-y-1">
                  {similar.map((x) => {
                    const on = peek === x.id;
                    return (
                      <li key={x.id}>
                        <button type="button" aria-expanded={on} onClick={() => setPeek(on ? null : x.id)} className={cn('flex min-h-11 w-full items-center justify-between gap-3 rounded-lg px-2 py-2 text-left text-[14px] text-foreground transition-colors hover:bg-accent', FOCUS)}>
                          <span>{highlight(x.question, text, 5)}</span>
                          <Icon name="chevron-down" size={15} className={cn('shrink-0 text-muted-foreground transition-transform duration-300', on && 'rotate-180')} />
                        </button>
                        {on && x.answers[0] && <p className="animate-[kitshop-in_0.3s_var(--ease-kit)] px-2 pb-2 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{x.answers[0].body}</p>}
                      </li>
                    );
                  })}
                </ul>
              </div>
            )}
            <label htmlFor={`${id}-e`} className="mb-2 mt-5 block text-[14px] font-medium text-foreground">
              {t.email}
            </label>
            <input
              id={`${id}-e`}
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              autoComplete="email"
              aria-invalid={(tried && badEmail) || undefined}
              aria-describedby={`${id}-eh`}
              className={cn(FIELD, 'h-11', tried && badEmail && 'border-destructive')}
            />
            <p id={`${id}-eh`} className={cn('mt-1.5 text-[12.5px]', tried && badEmail ? 'text-destructive' : 'text-muted-foreground')}>
              {tried && badEmail ? t.emailError : t.emailHint}
            </p>
            <div className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
              {p.answerTime && (
                <p className="flex items-center gap-1.5 text-[13px] text-muted-foreground">
                  <Icon name="clock" size={14} />
                  {t.thanksText} {p.answerTime}
                </p>
              )}
              <Button type="submit" iconLeft={state === 'sending' ? 'loader' : 'send'} disabled={state === 'sending'}>
                <Swap value={state === 'sending'}>{state === 'sending' ? t.sending : t.send}</Swap>
              </Button>
            </div>
          </form>
        )}
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* by-topic                                                            */
/* ------------------------------------------------------------------ */

function ByTopic(p: ProductQaProps & { t: Texts }) {
  const { t } = p;
  const locale = useLocale();
  const topics = p.topics ?? [];
  const [topic, setTopic] = useState<string | null>(null);
  const all = p.questions ?? [];
  const shown = topic ? all.filter((x) => x.topic === topic) : all;
  const count = (id: string | null) => (id ? all.filter((x) => x.topic === id).length : all.length);
  const items = [{ id: null as string | null, label: t.allTopics }, ...topics.map((x) => ({ id: x.id as string | null, label: x.label }))];
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <div className="grid grid-cols-1 gap-6 lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-12">
        <nav aria-label={t.allTopics} className="lg:sticky lg:top-8 lg:self-start">
          <ul className="kit-no-scrollbar kit-fade-end -mx-6 flex gap-2 overflow-x-auto px-6 py-1 lg:mx-0 lg:flex-col lg:gap-0.5 lg:overflow-visible lg:px-0 lg:[mask-image:none]">
            {items.map((x) => {
              const on = topic === x.id;
              return (
                <li key={x.id ?? 'all'} className="shrink-0">
                  <button
                    type="button"
                    aria-pressed={on}
                    onClick={() => setTopic(x.id)}
                    className={cn(
                      'flex h-11 w-full items-center justify-between gap-3 whitespace-nowrap rounded-full border px-4 text-[14px] font-medium transition-colors lg:rounded-lg lg:border-0 lg:px-3',
                      on ? 'border-foreground bg-foreground text-background lg:bg-muted lg:text-foreground' : 'border-border-strong text-muted-foreground hover:text-foreground lg:hover:bg-surface',
                      FOCUS,
                    )}
                  >
                    {x.label}
                    <span className={cn('text-[12.5px] tabular-nums', on ? 'opacity-70 lg:opacity-100 lg:text-muted-foreground' : 'text-subtle-foreground')}>{formatNumber(locale, count(x.id))}</span>
                  </button>
                </li>
              );
            })}
          </ul>
        </nav>
        <ul key={topic ?? 'all'} className="grid grid-cols-1 gap-4 sm:grid-cols-2">
          {shown.map((x, i) => {
            const a = x.answers[0];
            return (
              <li key={x.id} className="animate-[kitshop-in_0.45s_var(--ease-kit)_both]" style={{ animationDelay: `${Math.min(i, 6) * 60}ms` }}>
                <article className={cn(CARD, 'flex h-full flex-col p-5 sm:p-6')}>
                  {!topic && x.topic && <p className="text-[12px] font-medium text-subtle-foreground">{topics.find((tp) => tp.id === x.topic)?.label}</p>}
                  <h3 className="mt-1 text-pretty text-[16px] font-semibold leading-snug text-foreground">{x.question}</h3>
                  {a && <p className="mt-3 text-pretty text-[14px] leading-relaxed text-muted-foreground">{a.body}</p>}
                  {a && (
                    <p className="mt-auto flex flex-wrap items-center gap-x-2 pt-4 text-[12.5px] text-muted-foreground">
                      <span className="font-medium text-foreground">{a.author}</span>
                      {a.role === 'shop' && <span className={ACCENT_TEXT}>· {a.title ?? t.fromShop}</span>}
                      {x.votes !== undefined && (
                        <span className="ms-auto inline-flex items-center gap-1 tabular-nums">
                          <Icon name="users" size={13} />
                          {formatNumber(locale, x.votes)} {t.asked}
                        </span>
                      )}
                    </p>
                  )}
                </article>
              </li>
            );
          })}
        </ul>
      </div>
    </Section>
  );
}

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

/**
 * Product questions in four layouts. list: search, answers and votes.
 * compact: the most asked as an accordion. ask: the question form with
 * similar questions. by-topic: questions sorted into topics.
 */
export function ProductQa(props: ProductQaProps) {
  const { variant = 'list' } = props;
  const t = useTexts(TEXTS, props.labels);
  if (variant === 'compact') return <Compact {...props} t={t} />;
  if (variant === 'ask') return <Ask {...props} t={t} />;
  if (variant === 'by-topic') return <ByTopic {...props} t={t} />;
  return <List {...props} t={t} />;
}

Frequent questions

How do I install Product questions?

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

Which variants does Product questions have?

4 variants, Questions and answers, Most asked, Ask a question and By topic. You pick one with the variant prop, for example variant="compact".

Can I translate the texts of Product questions?

Yes. Product questions carries 28 texts of its own in English and German, such as “Search questions”, “Search questions and answers” and “questions”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Product questions?

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

Can I adjust Product questions without code?

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

Often searched

Jump to

Copied to the clipboard