Live polls
Add an interactive poll to any page with an <smp-poll> block. Votes persist and tally live, with no backend of your own.
A page can collect live responses. Drop an <smp-poll> block into your HTML and
it renders as a set of clickable options with a live results bar. Votes are
stored for you and counted across everyone who opens the link, so a shared page
can be a real poll, ballot, or quick vote, not just a static document.
You do not need a backend, an API key, or any JavaScript of your own. The block is the whole contract.
Results shown on the page are counts only. A live "who voted for what" view is deliberately not possible: per-voter identity stays private to the page owner, who reads it via CSV export (see Capturing who voted).
A basic poll
<smp-poll id="lunch">
<smp-poll-question>Where should we go for the team lunch?</smp-poll-question>
<smp-poll-option value="sushi">Sushi</smp-poll-option>
<smp-poll-option value="pizza">Pizza</smp-poll-option>
<smp-poll-option value="salad">Salad bar</smp-poll-option>
</smp-poll>id(required) is a stable key for this poll. Keep it unique on the page and do not change it later, it is what the votes are filed under.- Each
<smp-poll-option value="...">is a choice. Thevalueis the key the vote is counted under; the text inside the tag is the visible label. <smp-poll-question>is optional and shown above the options.
When the page is viewed, each option becomes a button with a live bar and a count. Clicking one records a vote and updates the bars for everyone.
Voting is one vote per person (per browser), and a voter can change their pick any time by clicking a different option, right up until you close it.
Capturing who voted
By default votes are anonymous. To capture each voter's name, add
require-name to the poll and put a single [data-smp-name] input on the page:
<input data-smp-name placeholder="Your name" />
<smp-poll id="award" require-name>
<smp-poll-option value="atlas">Project Atlas</smp-poll-option>
<smp-poll-option value="ledger">Ledger API</smp-poll-option>
</smp-poll>Now a vote is blocked until the name field is filled, and the name is stored
with the vote. The public results stay anonymous, you see who voted in the
page's Responses panel (see below). One name field serves every
require-name poll on the page.
Seeing and managing responses
Open the page's settings, under Responses you get, per poll:
- the live tally,
- Close voting (freezes the results), Pause, and Reset,
- Export CSV of the individual responses, including any captured names.
Closing a poll freezes its bars and stops new votes. Reset clears a poll's
responses and reopens it. These act on one poll (one id) at a time.
Email notifications
The page owner gets an email when votes come in, the first one right away and then at most once an hour, with a running count of new votes since the last email. The email links straight to this Responses view; it never includes who voted or how, so read that from Export CSV. This is on by default, and every email has a one-click unsubscribe link.
Styling a poll
The poll ships with no styles of its own. It renders into the page's own DOM (there is no shadow root), and its buttons and bars are unstyled, so your page CSS fully controls how it looks.
The one catch: style the rendered structure, not the authoring tags. When
the page loads, each <smp-poll-option> is replaced by a <button>, so CSS
that targets smp-poll-option has no effect. Target the rendered nodes and
their data-smp-* hooks instead. This is what a live poll expands to:
<smp-poll id="lunch">
Where should we go for the team lunch? <!-- question text, kept in place -->
<div data-smp-poll="lunch">
<button type="button" data-smp-poll-option="sushi">
<span>Sushi</span> <!-- the label -->
<span data-smp-poll-bar><span></span></span> <!-- track > fill -->
<span data-smp-poll-count>0</span> <!-- the count -->
</button>
<!-- one <button> per option -->
</div>
</smp-poll>The stable hooks you can style and select against:
| Selector | What it is |
|---|---|
[data-smp-poll="<id>"] | the container wrapping the option buttons |
[data-smp-poll-option="<value>"] | one option button (has disabled when the poll is closed) |
[data-smp-poll-bar] | the results-bar track; its child <span> is the fill (its width is the percentage) |
[data-smp-poll-count] | the numeric count for that option |
The container also carries live state you can react to: data-smp-total (the
running total), data-smp-poll-closed (present once voting is closed), and
data-smp-need-name (present when a require-name vote was blocked because the
name field was empty).
[data-smp-poll="lunch"] [data-smp-poll-option] {
display: block;
width: 100%;
border: 1px solid #ddd;
border-radius: 12px;
padding: 12px 16px;
}
[data-smp-poll="lunch"] [data-smp-poll-bar] {
display: block;
height: 6px;
background: #f0f0f0;
}
[data-smp-poll="lunch"] [data-smp-poll-bar] > span {
display: block;
height: 100%;
background: #0460d9; /* the fill; width is set for you */
}
[data-smp-poll="lunch"][data-smp-poll-closed] [data-smp-poll-option] {
opacity: 0.6;
}Want a fully bespoke ballot? Hide the default buttons with CSS, render your own, and forward each click to the real option button (see Building your own widget). You keep the vote engine and get any look you want, without rebuilding storage.
Building your own widget
For anything beyond a poll, window.smp is the raw escape hatch, available to
any inline script on the page:
// append a response to a named stream (one row per visitor, updatable)
await window.smp.append("guestbook", { name: "Sam", note: "Nice work" });
// read the running aggregate for a stream
const agg = await window.smp.read("guestbook");Both calls resolve to the same aggregate object, or to null when the
stream has nothing yet or the write was rejected (closed, paused, over quota,
rate-limited, or a network failure). Always null-check before you read fields:
{
total: 12, // total responses counted
buckets: { sushi: 7, pizza: 4, salad: 1 }, // per-option counts; {} for a plain stream
closed: false // true once the owner closes voting
}buckets is populated only for <smp-poll> blocks (keyed by each option's
value); a plain window.smp.append stream carries no per-option key, so its
buckets is {} and you use total. The aggregate never includes individual
responses or names, those stay private to the owner's Export CSV. Use this
for guestbooks, counters, or RSVP tallies where a total is enough.
Reading and driving a poll from your own script
A <smp-poll> stores its votes under its id, so you can read its live
aggregate by passing that id:
const agg = await window.smp.read("lunch"); // the poll's id
// agg.buckets.sushi, agg.buckets.pizza, ... and agg.totalTo cast a vote from your own UI, do not call window.smp.append. That
appends to a separate general-purpose stream (one updatable row per visitor,
with no per-option bucket), so it can never register as a poll vote. Instead,
click the rendered option button, the same action a normal click performs:
document
.querySelector('[data-smp-poll="lunch"] [data-smp-poll-option="sushi"]')
.click();That is the supported way to put a bespoke ballot over the poll engine: hide the default buttons with CSS (see Styling a poll), render your own controls, and forward each of your clicks to the matching option button.
Notes and limits
- Storage is per page. Responses are tied to the page and its block
ids. If you change a poll'sid(or remove it), its old responses stay filed under the oldid, reset them from the Responses panel if you no longer want them. - Fair-use caps apply. Each page has a response cap plus per-visitor and per-IP rate limits, and you can pause a poll instantly. These protect against abuse; they are not tuned for high-frequency or high-volume data collection.
- Names are self-reported.
require-namecaptures whatever the voter types. It is a convenience for knowing who voted, not identity verification. - Malformed poll markup (a missing
id, or no options) is left as plain text rather than upgraded, so a typo never breaks the page.