Skip to content

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 poll
npx newtui@latest add poll
yarn dlx newtui@latest add poll
bunx newtui@latest add poll

Copy 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

PropType
children / default slot?React.ReactNode / Vue slot

Content rendered inside the component.

TypeReact.ReactNode / Vue slot
className / class?string

Additional classes merged with the component defaults.

Typestring

Accessibility

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.