Poll Builder
Compose a poll: a question, 2–6 options to add, reorder and remove, multiple choice and duration, live preview.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/poll-builder.json'use client'
import { useId, useRef, useState } from 'react'
import './poll-builder.css'
export interface PollBuilderProps {
onPublish?: (p: { q: string; options: string[]; multi: boolean; hours: number }) => void
max?: number
preview?: boolean
defaultQuestion?: string
defaultOptions?: string[]
fill?: 'dark' | 'light'
className?: string
}
/** Poll composer with reorder, settings and preview. */
export function PollBuilder({ onPublish, max = 6, preview = true, defaultQuestion = '', defaultOptions = ['', ''], fill = 'dark', className }: PollBuilderProps) {
const [q, setQ] = useState(defaultQuestion)
const [opts, setOpts] = useState(defaultOptions)
const [multi, setMulti] = useState(false)
const [hours, setHours] = useState(24)
const [done, setDone] = useState(false)
const box = useRef<HTMLDivElement>(null)
const id = useId()
const clean = opts.map((o) => o.trim()).filter(Boolean)
const dup = new Set(clean.map((o) => o.toLowerCase())).size !== clean.length
const err = !q.trim() ? 'Add a question' : clean.length < 2 ? 'Add at least two options' : dup ? 'Options must be different' : ''
const focus = (i: number) => requestAnimationFrame(() => box.current?.querySelectorAll<HTMLInputElement>('.sd-plb__o input')[i]?.focus())
const move = (i: number, d: number) => setOpts((o) => { const n = o.slice(); [n[i], n[i + d]] = [n[i + d], n[i]]; return n })
if (done) return (
<div className={['sd-plb', className].filter(Boolean).join(' ')} data-fill={fill} role="status">
<div className="sd-plb__pv"><small>Poll posted · ends in {hours}h</small><b>{q}</b>{clean.map((o) => <span key={o}><i />{o}</span>)}</div>
<button type="button" className="sd-plb__go" onClick={() => setDone(false)}>Edit poll</button>
</div>
)
return (
<div ref={box} className={['sd-plb', className].filter(Boolean).join(' ')} data-fill={fill} data-multi={multi || undefined}>
<input className="sd-plb__q" aria-label="Question" placeholder="Ask a question…" value={q} onChange={(e) => setQ(e.target.value)} />
<ol className="sd-plb__opts">
{opts.map((o, i) => (
<li key={i} className="sd-plb__o">
<input aria-label={`Option ${i + 1}`} placeholder={`Option ${i + 1}`} value={o} maxLength={60}
onChange={(e) => setOpts((x) => x.map((y, k) => (k === i ? e.target.value : y)))}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); if (i === opts.length - 1 && opts.length < max && o.trim()) { setOpts([...opts, '']); focus(i + 1) } else focus(i + 1) } }} />
<button type="button" className="sd-plb__ib" aria-label={`Move option ${i + 1} up`} disabled={i === 0} onClick={() => move(i, -1)}>↑</button>
<button type="button" className="sd-plb__ib" aria-label={`Move option ${i + 1} down`} disabled={i === opts.length - 1} onClick={() => move(i, 1)}>↓</button>
<button type="button" className="sd-plb__ib" aria-label={`Remove option ${i + 1}`} disabled={opts.length <= 2} onClick={() => setOpts((x) => x.filter((_, k) => k !== i))}>✕</button>
</li>
))}
</ol>
<button type="button" className="sd-plb__add" disabled={opts.length >= max} onClick={() => { setOpts([...opts, '']); focus(opts.length) }}>+ Add option {opts.length >= max ? `(max ${max})` : ''}</button>
<div className="sd-plb__set"><span id={`${id}m`}>Allow multiple answers</span><button type="button" role="switch" aria-checked={multi} aria-labelledby={`${id}m`} className="sd-plb__sw" onClick={() => setMulti(!multi)} /></div>
<div className="sd-plb__set" style={{ borderTop: 0, paddingTop: 0 }}><label htmlFor={`${id}d`}>Runs for</label><select id={`${id}d`} value={hours} onChange={(e) => setHours(+e.target.value)}>{[1, 24, 72, 168].map((h) => <option key={h} value={h}>{h < 24 ? '1 hour' : h === 24 ? '1 day' : h === 72 ? '3 days' : '1 week'}</option>)}</select></div>
{preview && <div className="sd-plb__pv" aria-label="Preview"><small>Preview · {multi ? 'choose any' : 'choose one'}</small><b>{q || 'Your question'}</b>{(clean.length ? clean : ['Option 1', 'Option 2']).map((o) => <span key={o}><i />{o}</span>)}</div>}
<button type="button" className="sd-plb__go" disabled={!!err} aria-describedby={`${id}e`} onClick={() => { setDone(true); onPublish?.({ q: q.trim(), options: clean, multi, hours }) }}>Post poll</button>
<p id={`${id}e`} className="sd-plb__msg">{err}</p>
</div>
)
}.sd-plb { --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-plb[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-plb :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-plb :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-plb .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-plb { width: min(100%, 330px); padding: 14px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-plb__q { width: 100%; padding: 4px 2px 10px; border: 0; border-bottom: 1px solid var(--line); background: none; color: var(--fg); font: 600 16px/1.3 ui-sans-serif, system-ui; outline: 0; }
.sd-plb__q::placeholder { color: var(--mute); }
.sd-plb__opts { list-style: none; margin: 10px 0 6px; padding: 0; display: grid; gap: 6px; }
.sd-plb__o { display: flex; align-items: center; gap: 4px; animation: sd-plb-in 200ms ease-out; }
.sd-plb__o input { flex: 1; min-width: 0; height: 36px; padding: 0 10px; border: 0; border-radius: 9px; background: var(--soft); color: var(--fg); font: inherit; font-size: 13.5px; outline: 0; }
.sd-plb__o input:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-plb__ib { display: grid; place-items: center; width: 26px; height: 30px; border-radius: 7px; color: var(--mute) !important; font-size: 12px; }
.sd-plb__ib:hover:not(:disabled) { background: var(--soft); color: var(--fg) !important; }
.sd-plb__ib:disabled { opacity: 0.25; }
.sd-plb__add { padding: 6px 8px; border-radius: 8px; font-size: 12.5px; color: var(--mute) !important; }
.sd-plb__add:disabled { opacity: 0.35; }
.sd-plb__set { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 10px; padding: 10px 0; border-top: 1px solid var(--line); font-size: 13px; }
.sd-plb__set select { height: 30px; padding: 0 6px; border: 0; border-radius: 8px; background: var(--soft); color: var(--fg); font: inherit; font-size: 12.5px; }
.sd-plb__sw { position: relative; width: 36px; height: 22px; border-radius: 99px; background: var(--line) !important; flex: none; }
.sd-plb__sw::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 99px; background: var(--fg); transition: transform 180ms cubic-bezier(0.3, 1.5, 0.4, 1); }
.sd-plb__sw[aria-checked='true'] { background: var(--strong) !important; }
.sd-plb__sw[aria-checked='true']::after { transform: translateX(14px); background: var(--on); }
.sd-plb__pv { margin: 6px 0 12px; padding: 12px; border-radius: 14px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-plb__pv small { font-size: 11px; color: var(--mute); text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.sd-plb__pv b { display: block; margin: 4px 0 8px; font-size: 14px; }
.sd-plb__pv span { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 13px; }
.sd-plb__pv span i { width: 14px; height: 14px; border-radius: 99px; box-shadow: inset 0 0 0 1.5px var(--mute); }
.sd-plb[data-multi] .sd-plb__pv span i { border-radius: 4px; }
.sd-plb__go { width: 100%; height: 42px; border-radius: 12px; background: var(--strong) !important; color: var(--on) !important; font-weight: 600; font-size: 14px; }
.sd-plb__go:disabled { opacity: 0.35; }
.sd-plb__msg { margin: 6px 2px 0; font-size: 12px; color: var(--mute); text-align: center; min-height: 16px; }
@keyframes sd-plb-in { from { opacity: 0; transform: translateY(-4px); } }
@media (prefers-reduced-motion: reduce) { .sd-plb *, .sd-plb *::before, .sd-plb *::after { animation: none !important; transition: none !important; } }Overview
Enter on an option adds the next one; empty trailing options are ignored. Arrow buttons reorder; options can’t go below two.
Settings cover multiple answers and how long it runs. The preview shows exactly what voters will see.
When to use
- Team chat, community posts, event planning.
When not to use
- Long surveys with branching.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/poll-builder.json$
npx shadcn@latest add https://sundaeui.dev/r/poll-builder.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/poll-builder.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/poll-builder.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 poll-builder.tsx and poll-builder.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 { PollBuilder } from '@/components/sundae/poll-builder'
<PollBuilder />Props
| Prop | Type | Default |
|---|---|---|
onPublishPublish callback. | (poll: { q: string; options: string[]; multi: boolean; hours: number }) => void | — |
maxMax options. | number | 6 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each option input is labelled “Option 2”; move buttons name the option.
- Validation messages are tied to the publish button.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-plb; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Enter in an option
- Add the next option
FAQ
- Is Poll Builder free?
- Yes. Poll Builder is free for personal and commercial projects, with full source on this page.
- How do I install Poll Builder?
- Run npx shadcn@latest add https://sundaeui.dev/r/poll-builder.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Poll Builder support keyboard and screen readers?
- Each option input is labelled “Option 2”; move buttons name the option. Validation messages are tied to the publish button.
- Does Poll Builder respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Rating Scale
Forms · RefinedWas This Helpful
Forms · RefinedLikert Matrix
Forms · RefinedThread Composer
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined