Product Q&A
Customer questions on a product: search existing answers first, vote answers helpful, and ask a new question inline.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/product-qa.json'use client'
import { useState, type ReactNode } from 'react'
import './product-qa.css'
export interface QaItem { id: string; q: string; a?: string; by?: 'Seller' | 'Owner'; helpful: number; age: number }
export interface ProductQaProps {
items: QaItem[]
sort?: 'helpful' | 'new'
onAsk?: (q: string) => void
fill?: 'dark' | 'light'
className?: string
}
const hi = (t: string, q: string): ReactNode => { if (!q) return t; const i = t.toLowerCase().indexOf(q.toLowerCase()); return i < 0 ? t : <>{t.slice(0, i)}<mark>{t.slice(i, i + q.length)}</mark>{t.slice(i + q.length)}</> }
/** Product questions with search-first asking and helpful votes. */
export function ProductQa({ items, sort = 'helpful', onAsk, fill = 'dark', className }: ProductQaProps) {
const [list, setList] = useState(items)
const [q, setQ] = useState('')
const [voted, setVoted] = useState<string[]>([])
const words = q.trim().toLowerCase().split(/\s+/).filter((w) => w.length > 2)
const shown = list.filter((x) => !words.length || words.some((w) => (x.q + ' ' + (x.a ?? '')).toLowerCase().includes(w))).sort(sort === 'new' ? (a, b) => a.age - b.age : (a, b) => b.helpful - a.helpful)
const ask = () => { const t = q.trim(); if (t.length < 8) return; setList([{ id: `q${Date.now()}`, q: t.endsWith('?') ? t : t + '?', helpful: 0, age: 0 }, ...list]); setQ(''); onAsk?.(t) }
return (
<div className={['sd-pqa', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-pqa__h"><b>Questions</b><small aria-live="polite">{words.length ? `${shown.length} match` : `${list.length} asked`}</small></div>
<form onSubmit={(e) => { e.preventDefault(); ask() }}>
<input aria-label="Search or ask a question" placeholder="Have a question? Search first" value={q} onChange={(e) => setQ(e.target.value)} />
<button type="submit" disabled={q.trim().length < 8}>{words.length && shown.length ? 'Ask anyway' : 'Ask'}</button>
</form>
<ul>
{shown.map((x) => (
<li key={x.id}>
<b>{hi(x.q, words[0] ?? '')}</b>
{x.a ? <p>{x.by && <em>{x.by}</em>}{x.a}</p> : <span className="sd-pqa__w">Waiting for an answer · we will email you</span>}
{x.a && <button type="button" className="sd-pqa__hl" aria-pressed={voted.includes(x.id)} onClick={() => { const on = voted.includes(x.id); setVoted(on ? voted.filter((v) => v !== x.id) : [...voted, x.id]); setList((l) => l.map((y) => (y.id === x.id ? { ...y, helpful: y.helpful + (on ? -1 : 1) } : y))) }}>Helpful · {x.helpful}</button>}
</li>
))}
{!shown.length && <li className="sd-pqa__none">No one has asked that yet. Ask it above.</li>}
</ul>
</div>
)
}.sd-pqa { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-pqa[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-pqa :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-pqa :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-pqa .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-pqa { width: min(100%, 340px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 10px; }
.sd-pqa__h { display: flex; justify-content: space-between; align-items: baseline; }
.sd-pqa__h b { font-size: 15px; }
.sd-pqa__h small { color: var(--mute); font-size: 12px; }
.sd-pqa form { display: flex; gap: 6px; }
.sd-pqa form input { flex: 1; min-width: 0; height: 38px; padding: 0 12px; border: 0; border-radius: 10px; background: var(--soft); color: var(--fg); font: inherit; font-size: 13px; outline: 0; }
.sd-pqa form input:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-pqa form button { padding: 0 14px; border-radius: 10px; font-size: 13px; font-weight: 600; background: var(--strong) !important; color: var(--on) !important; }
.sd-pqa form button:disabled { opacity: 0.4; }
.sd-pqa ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; max-height: 330px; overflow-y: auto; }
.sd-pqa li { padding: 10px 0; border-bottom: 1px solid var(--line); display: grid; gap: 6px; animation: sd-pqa-in 220ms ease-out; }
@keyframes sd-pqa-in { from { opacity: 0; transform: translateY(-4px); } }
.sd-pqa li > b { font-size: 13.5px; }
.sd-pqa li > b mark { background: var(--fg); color: var(--bg); border-radius: 2px; padding: 0 1px; }
.sd-pqa li p { margin: 0; font-size: 13px; color: var(--mute); }
.sd-pqa li p em { font-style: normal; font-size: 11px; font-weight: 700; padding: 1px 6px; border-radius: 99px; box-shadow: inset 0 0 0 1px var(--line); color: var(--fg); margin-right: 6px; }
.sd-pqa__w { font-size: 12px; color: var(--mute); font-style: italic; }
.sd-pqa__hl { justify-self: start; display: flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border-radius: 99px; font-size: 11.5px; box-shadow: inset 0 0 0 1px var(--line); font-variant-numeric: tabular-nums; }
.sd-pqa__hl[aria-pressed='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-pqa__none { padding: 20px 0; text-align: center; font-size: 12.5px; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-pqa *, .sd-pqa *::before, .sd-pqa *::after { animation: none !important; transition: none !important; } }Overview
Typing in the box filters existing questions; if nothing matches, the same box becomes Ask. Answers show who answered (seller or owner) and a helpful count.
New questions appear at the top marked as awaiting an answer.
When to use
- Product pages, marketplace listings.
When not to use
- Support tickets: use a help desk.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/product-qa.json$
npx shadcn@latest add https://sundaeui.dev/r/product-qa.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/product-qa.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/product-qa.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy product-qa.tsx and product-qa.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { ProductQa } from '@/components/sundae/product-qa'
<ProductQa />Props
| Prop | Type | Default |
|---|---|---|
items*Questions. | { id: string; q: string; a?: string; by?: string; helpful: number }[] | — |
onAskCalled on ask. | (q: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Helpful is a toggle button with its count.
- The filter result count is announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-pqa; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Product Q&A free?
- Yes. Product Q&A is free for personal and commercial projects, with full source on this page.
- How do I install Product Q&A?
- Run npx shadcn@latest add https://sundaeui.dev/r/product-qa.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Product Q&A support keyboard and screen readers?
- Helpful is a toggle button with its count. The filter result count is announced.
- Does Product Q&A respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Hotspot Image
Components · RefinedRoadmap Votes
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined