Navigation

v0.0.4
Forms

Poll

A question, a few answers, and how the votes fell once one is chosen.

On this page

A poll is a question, a few answers, and how the votes fell. Each answer is a <label> around a native radio, so the group keeps the arrow keys and a form keeps the value. Before a vote the answers are flat faces on one contour. Once one is chosen, every answer fills from its start with a tint as long as its share and its figure appears at the end. The checked input is all that drives it: there is no script.

Poll

<div class="card w-full max-w(360)">  <fieldset class="poll">    <legend class="poll-title">Which theme should be the default?</legend>    <label class="poll-option" style="--value: 58">      <input class="poll-input" type="radio" name="theme" value="moonstone">      <span class="poll-label">Moonstone</span>      <span class="poll-value">58%</span>    </label>    <label class="poll-option" style="--value: 31">      <input class="poll-input" type="radio" name="theme" value="obsidian">      <span class="poll-label">Obsidian</span>      <span class="poll-value">31%</span>    </label>    <label class="poll-option" style="--value: 11">      <input class="poll-input" type="radio" name="theme" value="system">      <span class="poll-label">Follow the system</span>      <span class="poll-value">11%</span>    </label>    <p class="poll-meta">214 votes · 2 days left</p>  </fieldset></div>
Choose an answer: the result shows.

#How the result shows

Each poll-option carries its share of the votes as --value, from 0 to 100, and the same figure as text in poll-value. While no input is checked, the shares have no width and the figures are hidden, from assistive technology too. When an answer is checked, :has() sees it on the group: every share grows to its width and every figure fades in.

The chosen answer takes an ink contour, a stronger tint and a check after its words. Choosing another answer moves all three; the shares stay.

The page supplies the numbers. If the result should include the vote just cast, update --value and the text when the vote is recorded: the shares ease to their new widths.

#A poll that is over

Disable the inputs when voting has ended. The result shows from the start, nothing can be chosen, and the answers stay as legible as before, since a result is there to be read. Keep checked on the answer the reader chose.

Closed

<div class="card w-full max-w(360)">  <fieldset class="poll">    <legend class="poll-title">Which theme should be the default?</legend>    <label class="poll-option" style="--value: 61">      <input class="poll-input" type="radio" name="theme-closed" value="moonstone" disabled>      <span class="poll-label">Moonstone</span>      <span class="poll-value">61%</span>    </label>    <label class="poll-option" style="--value: 29">      <input class="poll-input" type="radio" name="theme-closed" value="obsidian" disabled checked>      <span class="poll-label">Obsidian</span>      <span class="poll-value">29%</span>    </label>    <label class="poll-option" style="--value: 10">      <input class="poll-input" type="radio" name="theme-closed" value="system" disabled>      <span class="poll-label">Follow the system</span>      <span class="poll-value">10%</span>    </label>    <p class="poll-meta">380 votes · Final result</p>  </fieldset></div>

#Several answers

Use checkboxes when a reader may choose more than one. The result shows as soon as one is checked, each chosen answer takes the contour and the check, and the shares no longer add up to 100.

Several answers

<div class="card w-full max-w(360)">  <fieldset class="poll">    <legend class="poll-title">Which frameworks do you ship with?</legend>    <label class="poll-option" style="--value: 72">      <input class="poll-input" type="checkbox" name="framework" value="react" checked>      <span class="poll-label">React</span>      <span class="poll-value">72%</span>    </label>    <label class="poll-option" style="--value: 34">      <input class="poll-input" type="checkbox" name="framework" value="svelte" checked>      <span class="poll-label">Svelte</span>      <span class="poll-value">34%</span>    </label>    <label class="poll-option" style="--value: 41">      <input class="poll-input" type="checkbox" name="framework" value="vue">      <span class="poll-label">Vue</span>      <span class="poll-value">41%</span>    </label>    <label class="poll-option" style="--value: 18">      <input class="poll-input" type="checkbox" name="framework" value="none">      <span class="poll-label">None: plain HTML</span>      <span class="poll-value">18%</span>    </label>    <p class="poll-meta">Choose all that apply</p>  </fieldset></div>

#Poll or choice

A poll asks a question whose answer is everyone's: choosing shows the result. A choice is an option of a form that explains itself in a sentence, and shows nothing afterwards. A result nobody votes on is a chart with chart-horizontal.

#Accessibility

  • Use a <fieldset> with the question in its <legend>, so the question is announced with each answer. Radios in it share a name.
  • Each answer is a <label> around its input, so the whole face is the target, 38px tall and 44px on a touch screen. The focus ring is drawn around the answer.
  • The figures are text. They are hidden with visibility until the result shows, so they are not read early, and they are in the document for a reader who cannot see the tint.
  • The check is the sign of the chosen answer for a reader who does not see the contour; in forced colors the chosen answer takes the system highlight.

#Classes

ClassDescription
pollThe group, on a fieldset or an element with role="group": answers 8px apart
poll-titleThe question, on the legend or a heading
poll-optionOne answer, on a label: a flat face 38px tall that fills from its start with a tint as long as its share; chosen, it takes an ink contour and a check
poll-inputThe native radio or checkbox inside the answer. It covers the answer and is not drawn
poll-labelThe answer's words
poll-valueThe answer's share as text, at the end; hidden until the result shows
poll-metaA quiet line under the answers: how many voted, how long is left

#Custom properties

PropertyDefaultDescription
--valueThe answer's share of the votes, from 0 to 100. On poll-option
--poll-colorvar(--color-surface-inverse)The ink the shares are tinted with
--poll-radiusvar(--radius-control)An answer's corner

#Source

@master/ui/components/poll.css
/* * Poll: a question, a few answers, and how the votes fell. * Each answer is a <label> around a native radio (or a checkbox, when several may be chosen), so the group * keeps the arrow keys and the form keeps the value. Before a vote the answers are flat faces on one contour. * Once one is chosen, every answer fills from its start with a tint as long as its share, set as --value * in percent, and its figure appears at the end; the chosen answer takes an ink contour and a check. * Nothing but the checked input drives this: :has() reads it, so there is no script. A poll that is over * has its inputs disabled and shows its result from the start. * Geometry: answers are 38px, as tall as a button (44 on a touch screen), 8px apart, with a control's corner. */@layer components {  .poll {    --poll-color: var(--color-surface-inverse);    --poll-radius: var(--radius-control);    display: grid;    gap: .5rem;    min-width: 0;    margin: 0;    padding: 0;    border: 0;    color: var(--color-text-body);    font-size: .875rem;    line-height: 1.45;    /* Voted, or over: the shares and their figures show. */    &:has(.poll-input:is(:checked, :disabled)) {      .poll-option::before {        width: calc(clamp(0, var(--value, 0), 100) * 1%);      }      .poll-value {        visibility: visible;        opacity: 1;      }    }  }  /* The question, on a <legend> or a heading. */  .poll-title {    margin: 0 0 .75rem;    padding: 0;    color: var(--color-text-strong);    font: var(--font-weight-control) .9375rem/1.4 var(--font-family-sans);    font-feature-settings: inherit;    text-wrap: balance;  }  /* One answer. */  .poll-option {    position: relative;    display: flex;    align-items: center;    gap: .75rem;    min-width: 0;    min-height: 2.375rem;    padding: .5rem .875rem;    border-radius: calc(var(--poll-radius) * var(--corner-scale));    corner-shape: var(--corner-shape);    /* The share is finished by the answer's own corner. */    overflow: clip;    background: var(--color-surface-raised);    color: var(--color-text-strong);    box-shadow: var(--shadow-flat);    font: var(--font-weight-control) .84375rem/1.25rem var(--font-family-sans);    font-feature-settings: inherit;    cursor: pointer;    touch-action: manipulation;    user-select: none;    -webkit-user-select: none;    -webkit-tap-highlight-color: transparent;    transition: box-shadow var(--duration-quick) var(--easing-glide);    /* The share: a tint of ink from the start edge, which grows to its width when the result shows. */    &::before {      content: '';      position: absolute;      inset-block: 0;      inset-inline-start: 0;      width: 0;      border-start-end-radius: .1875rem;      border-end-end-radius: .1875rem;      background: color-mix(in oklab, var(--poll-color) 7%, transparent);      transition: width var(--duration-base) var(--easing-glide);    }    @media (hover: hover) {      &:hover:not(:has(:checked, :disabled)) {        box-shadow: 0 0 0 .5px var(--color-line-mark);      }    }    /* Pressed: the answer sinks into a well before it is chosen. */    &:active:not(:has(:checked, :disabled)) {      background: var(--color-surface-inset);      box-shadow: var(--shadow-pressed);      transition-duration: var(--duration-press);    }    /* Chosen: an ink contour, a stronger tint and a check after the words. */    &:has(.poll-input:checked) {      box-shadow:        inset 0 1px 0 var(--color-highlight),        0 0 0 .5px var(--color-text-strong), 0 0 0 1px color-mix(in oklab, var(--color-text-strong) 22%, transparent),        0 1px 2px -1px var(--color-shade-1), 0 2px 4px -2px var(--color-shade-2);      &::before {        background: color-mix(in oklab, var(--poll-color) 13%, transparent);      }      .poll-label::after {        content: '';        display: inline-block;        width: .875rem;        height: .875rem;        margin-inline-start: .375rem;        background: currentColor;        mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m3.5 8.5 3 3 6-7'/%3E%3C/svg%3E") center / contain no-repeat;        vertical-align: -.125em;      }      .poll-value {        color: var(--color-text-strong);      }    }    &:has(.poll-input:focus-visible) {      @apply --focus-ring;    }    /* Over: nothing more to choose, and the result stays as legible as before. */    &:has(.poll-input:disabled) {      cursor: default;    }    @media (pointer: coarse) {      min-height: 2.75rem;    }    @media (prefers-reduced-motion: reduce) {      &::before {        transition: none;      }    }    @media (forced-colors: active) {      outline: 1px solid CanvasText;      outline-offset: -1px;      &::before {        background: GrayText;        opacity: .3;        forced-color-adjust: none;      }      &:has(.poll-input:checked) {        outline: 2px solid Highlight;        outline-offset: -2px;      }    }  }  /* The native input covers the answer and is not drawn: the answer itself is its face. */  .poll-input {    position: absolute;    inset: 0;    width: 100%;    height: 100%;    margin: 0;    opacity: 0;    appearance: none;    cursor: inherit;  }  /* The answer's words. */  .poll-label {    position: relative;    flex: 1 1 auto;    min-width: 0;    overflow-wrap: anywhere;  }  /* Its share of the votes, in tabular figures: hidden until the result shows. */  .poll-value {    position: relative;    flex: none;    visibility: hidden;    color: var(--color-text-muted);    font-weight: 400;    font-variant-numeric: tabular-nums;    opacity: 0;    transition: opacity var(--duration-base) var(--easing-glide), visibility var(--duration-base);  }  /* A quiet line under the answers: how many voted, how long is left. */  .poll-meta {    margin: .25rem 0 0;    color: var(--color-text-muted);    font-size: .78125rem;    font-variant-numeric: tabular-nums;  }}