Poll
A question, its answers, and the share of the vote behind each one.
A question, its answers, and the share of the vote behind each one. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.
"use client"
import * as React from "react"
import {
Poll,
PollAnswer,
PollAnswers,
PollFooter,
PollQuestion,
} from "@/registry/bases/newt/ui/poll"
export default function PollDemo() {
const [answer, setAnswer] = React.useState<readonly string[]>(["friday"])
return (
<Poll total={21} showResults value={answer} onValueChange={setAnswer}>
<PollQuestion>Which day works for the raid?</PollQuestion>
<PollAnswers>
<PollAnswer value="friday" votes={13} emoji="🌙">
Friday night
</PollAnswer>
<PollAnswer value="sunday" votes={8} emoji="☀️">
Sunday afternoon
</PollAnswer>
</PollAnswers>
<PollFooter>21 votes · 4 hours left</PollFooter>
</Poll>
)
}<script setup lang="ts">
import { ref } from "vue"
import {
Poll,
PollAnswer,
PollAnswers,
PollFooter,
PollQuestion,
} from "~~/registry/bases/newt/ui/poll"
const answer = ref<readonly string[]>(["friday"])
</script>
<template>
<Poll v-model="answer" :total="21" show-results>
<PollQuestion>Which day works for the raid?</PollQuestion>
<PollAnswers>
<PollAnswer value="friday" :votes="13" emoji="🌙"
>Friday night</PollAnswer
>
<PollAnswer value="sunday" :votes="8" emoji="☀️">
Sunday afternoon
</PollAnswer>
</PollAnswers>
<PollFooter>21 votes · 4 hours left</PollFooter>
</Poll>
</template>Installation
pnpm dlx newtui@latest add pollnpx newtui@latest add pollyarn dlx newtui@latest add pollbunx newtui@latest add pollCopy the source into your project
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
/*
* A poll in a message: a question, the answers, and what everyone picked.
*
* The bar behind each answer is the result, so it only exists once the results
* are showing — before that the row is a button and nothing more. Percentages
* are computed here from the counts rather than passed in, because two numbers
* that must agree are one number too many.
*/
interface PollContextValue {
readonly total: number
readonly showResults: boolean
readonly selected: readonly string[]
readonly choose: (value: string) => void
readonly multiple: boolean
}
const PollContext = React.createContext<PollContextValue | null>(null)
function usePoll(component: string): PollContextValue {
const context = React.useContext(PollContext)
if (context === null) {
throw new Error(`<${component}> must be used inside a <Poll>.`)
}
return context
}
export interface PollProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
"onChange"
> {
/** Every vote cast, which is what the percentages are taken out of. */
total?: number
/** Shows the bars and the counts. A closed poll always shows them. */
showResults?: boolean
/** Whether more than one answer can be picked. */
multiple?: boolean
/** Controlled selection. */
value?: readonly string[]
defaultValue?: readonly string[]
onValueChange?: (value: readonly string[]) => void
}
const Poll = React.forwardRef<HTMLDivElement, PollProps>(
(
{
className,
total = 0,
showResults = false,
multiple = false,
value,
defaultValue = [],
onValueChange,
children,
...props
},
ref
) => {
const [internal, setInternal] =
React.useState<readonly string[]>(defaultValue)
const isControlled = value !== undefined
const selected = isControlled ? value : internal
const choose = React.useCallback(
(answer: string) => {
const next = multiple
? selected.includes(answer)
? selected.filter((each) => each !== answer)
: [...selected, answer]
: [answer]
if (!isControlled) setInternal(next)
onValueChange?.(next)
},
[multiple, selected, isControlled, onValueChange]
)
const context = React.useMemo<PollContextValue>(
() => ({ total, showResults, selected, choose, multiple }),
[total, showResults, selected, choose, multiple]
)
return (
<PollContext.Provider value={context}>
<div
ref={ref}
className={cn(
"flex w-full max-w-[440px] flex-col gap-3 rounded-lg border border-newt-border bg-newt-bg-surface p-4",
className
)}
{...props}
>
{children}
</div>
</PollContext.Provider>
)
}
)
Poll.displayName = "Poll"
const PollQuestion = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn(
"font-display text-base font-bold leading-snug text-newt-text-primary",
className
)}
{...props}
/>
))
PollQuestion.displayName = "PollQuestion"
const PollAnswers = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col gap-2", className)} {...props} />
))
PollAnswers.displayName = "PollAnswers"
export interface PollAnswerProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"value"
> {
/** What this answer is called in the poll's value. */
value: string
/** How many people picked it. */
votes?: number
/** The emoji beside the answer, if it has one. */
emoji?: React.ReactNode
}
const PollAnswer = React.forwardRef<HTMLButtonElement, PollAnswerProps>(
(
{
className,
value,
votes = 0,
emoji,
disabled,
children,
onClick,
...props
},
ref
) => {
const poll = usePoll("PollAnswer")
const chosen = poll.selected.includes(value)
const share = poll.total === 0 ? 0 : Math.round((votes / poll.total) * 100)
return (
<button
ref={ref}
type="button"
aria-pressed={chosen}
disabled={disabled}
onClick={(event) => {
onClick?.(event)
if (event.defaultPrevented || disabled) return
poll.choose(value)
}}
data-chosen={chosen || undefined}
className={cn(
"relative flex items-center gap-3 overflow-hidden rounded-md border border-newt-border bg-newt-bg-base px-3 py-2.5 text-start text-sm text-newt-text-secondary",
"transition-colors duration-fast ease-newt hover:border-newt-text-muted",
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link",
"data-[chosen]:border-newt-brand data-[chosen]:text-newt-text-primary",
disabled && "cursor-not-allowed opacity-60",
className
)}
{...props}
>
{/*
* The bar sits behind the label rather than beside it: the row keeps
* its height whatever the result is, so the list does not shuffle when
* the votes land.
*/}
{poll.showResults ? (
<span
aria-hidden="true"
style={{ inlineSize: `${share}%` }}
className="absolute inset-y-0 start-0 bg-newt-brand/25"
/>
) : null}
{emoji === undefined ? null : (
<span aria-hidden="true" className="relative shrink-0 text-base">
{emoji}
</span>
)}
<span className="relative min-w-0 flex-1 truncate">{children}</span>
{poll.showResults ? (
<span className="relative shrink-0 text-xs tabular-nums text-newt-text-muted">
{share}%
</span>
) : null}
</button>
)
}
)
PollAnswer.displayName = "PollAnswer"
const PollFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"flex items-center gap-2 text-xs text-newt-text-muted",
className
)}
{...props}
/>
))
PollFooter.displayName = "PollFooter"
export { Poll, PollAnswer, PollAnswers, PollFooter, PollQuestion }<script setup lang="ts">
import { computed, provide, ref, toRef, watch, type HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { pollKey } from "."
/*
* A poll in a message: a question, the answers, and what everyone picked.
*
* The bar behind each answer is the result, so it only exists once the results
* are showing. Percentages are computed from the counts rather than passed in,
* because two numbers that must agree are one number too many.
*/
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
/** Every vote cast, which is what the percentages are taken out of. */
total?: number
/** Shows the bars and the counts. A closed poll always shows them. */
showResults?: boolean
/** Whether more than one answer can be picked. */
multiple?: boolean
/** Controlled selection (use with v-model). */
modelValue?: readonly string[]
defaultValue?: readonly string[]
}>(),
{
total: 0,
showResults: false,
multiple: false,
modelValue: undefined,
defaultValue: () => [],
}
)
const emit = defineEmits<{
(e: "update:modelValue", value: readonly string[]): void
}>()
const internal = ref<readonly string[]>(props.modelValue ?? props.defaultValue)
watch(
() => props.modelValue,
(next) => {
if (next !== undefined) internal.value = next
}
)
const selected = computed(() => props.modelValue ?? internal.value)
function choose(value: string) {
const next = props.multiple
? selected.value.includes(value)
? selected.value.filter((each) => each !== value)
: [...selected.value, value]
: [value]
if (props.modelValue === undefined) internal.value = next
emit("update:modelValue", next)
}
provide(pollKey, {
total: toRef(props, "total"),
showResults: toRef(props, "showResults"),
selected,
choose,
})
</script>
<template>
<div
:class="
cn(
'flex w-full max-w-[440px] flex-col gap-3 rounded-lg border border-newt-border bg-newt-bg-surface p-4',
props.class
)
"
>
<slot />
</div>
</template><script setup lang="ts">
import { computed, inject, type HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { pollKey } from "."
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
/** What this answer is called in the poll's value. */
value: string
/** How many people picked it. */
votes?: number
/** The emoji beside the answer, if it has one. */
emoji?: string
disabled?: boolean
}>(),
{ votes: 0, disabled: false }
)
const poll = inject(pollKey)
if (poll === undefined) {
throw new Error("<PollAnswer> must be used inside a <Poll>.")
}
const chosen = computed(() => poll.selected.value.includes(props.value))
const share = computed(() =>
poll.total.value === 0
? 0
: Math.round((props.votes / poll.total.value) * 100)
)
</script>
<template>
<button
type="button"
:aria-pressed="chosen"
:disabled="props.disabled"
:data-chosen="chosen ? true : undefined"
:class="
cn(
'relative flex items-center gap-3 overflow-hidden rounded-md border border-newt-border bg-newt-bg-base px-3 py-2.5 text-start text-sm text-newt-text-secondary',
'transition-colors duration-fast ease-newt hover:border-newt-text-muted',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link',
'data-[chosen]:border-newt-brand data-[chosen]:text-newt-text-primary',
props.disabled && 'cursor-not-allowed opacity-60',
props.class
)
"
@click="poll.choose(props.value)"
>
<!--
The bar sits behind the label rather than beside it: the row keeps its
height whatever the result is, so the list does not shuffle when the
votes land.
-->
<span
v-if="poll.showResults.value"
aria-hidden="true"
:style="{ inlineSize: `${share}%` }"
class="absolute inset-y-0 start-0 bg-newt-brand/25"
/>
<span
v-if="props.emoji"
aria-hidden="true"
class="relative shrink-0 text-base"
>
{{ props.emoji }}
</span>
<span class="relative min-w-0 flex-1 truncate"><slot /></span>
<span
v-if="poll.showResults.value"
class="relative shrink-0 text-xs tabular-nums text-newt-text-muted"
>
{{ share }}%
</span>
</button>
</template><script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div :class="cn('flex flex-col gap-2', props.class)">
<slot />
</div>
</template><script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div
:class="
cn('flex items-center gap-2 text-xs text-newt-text-muted', props.class)
"
>
<slot />
</div>
</template><script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<p
:class="
cn(
'font-display text-base font-bold leading-snug text-newt-text-primary',
props.class
)
"
>
<slot />
</p>
</template>import type { ComputedRef, InjectionKey, Ref } from "vue"
export { default as Poll } from "./Poll.vue"
export { default as PollAnswer } from "./PollAnswer.vue"
export { default as PollAnswers } from "./PollAnswers.vue"
export { default as PollFooter } from "./PollFooter.vue"
export { default as PollQuestion } from "./PollQuestion.vue"
/** What an answer needs from the poll it sits in. */
export interface PollContext {
readonly total: Ref<number>
readonly showResults: Ref<boolean>
readonly selected: ComputedRef<readonly string[]>
readonly choose: (value: string) => void
}
export const pollKey: InjectionKey<PollContext> = Symbol("newt-poll")Update the import paths
The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.
Usage
import { Poll } from "@/components/ui/poll"
export function Example() {
return <Poll />
}<script setup lang="ts">
import { Poll, PollAnswer, PollAnswers, PollFooter, PollQuestion } from "@/components/ui/poll"
</script>
<template>
<Poll />
</template>API reference
Poll
children / default slot?React.ReactNode / Vue slot
Content rendered inside the component.
React.ReactNode / Vue slotclassName / class?string
Additional classes merged with the component defaults.
stringAccessibility
Preserve the native semantics of the rendered elements and pass through ARIA attributes when the surrounding context needs a label or description. Any interactive child needs a visible focus indicator, and meaning must not rely on colour alone.
Tokens
Poll reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer.