Skip to content

Upvotekit website widget

Add your Upvotekit feedback board to any website with one <script> tag. Show it as a floating button with a side panel, as a small popover, or right inside one of your pages. Visitors can browse, vote, post ideas and read your roadmap and changelog without leaving your site.

For AI agents and LLMs: this guide as plain Markdown → /upvotekit-widget.md

At a glance

  • One tag: <script src="https://upvotekit.com/widget.js" data-project="your-project" async></script>. Replace your-project with your project's slug: the snippet in Settings → Embed already contains it, and it's the <slug> in your public board's address upvotekit.com/p/<slug>.
  • Three display modes: panel (default), popover and inline. Pick one with data-display.
  • Options are data-* attributes on the script tag. Everything has a sensible default; only data-project is required.
  • A JavaScript API: window.Upvotekit(command, …) opens, closes and identifies, and lets you listen for events.
  • Signed-in users: your server creates a short-lived embed token, your page passes it with Upvotekit('identify', token). Votes and posts are then linked to that user, and private boards become visible to them.
  • Small and isolated: about 5 KB gzipped, no dependencies, no global variables besides Upvotekit, styles in a shadow root. Nothing else loads until the board is needed.
  • No tracking: the widget sends no analytics.
  • Everything the board shows comes from your project: colors, fonts, style, boards and moderation are set in Upvotekit, not in the tag.

Quick start

  1. Copy the snippet. In Upvotekit, open Settings → Embed (or Developers → Embed). Pick a display mode and copy the tag. It looks like this:

    <script src="https://upvotekit.com/widget.js" data-project="your-project" async></script>
    
  2. Paste it into your site. For panel and popover, paste it just before </body> on every page that should show the Feedback button (or in your site's global footer or layout). For inline, paste it where the board should appear.

  3. Check your allowed origins. If Settings → Embed → Allowed embed origins lists any sites, add your website's origin there (for example https://www.example.com). If the list is empty, any site may show the board.

That's it. Reload your site and you'll see a Feedback button in the bottom-right corner.

Choose how it shows

Set data-display to one of three modes.

Mode What visitors see Good for
panel (default) A floating button. Clicking it slides a panel in from the side, over a dimmed page. On phones the panel fills the screen. Most sites: feedback is one click away on every page.
popover A floating button. Clicking it opens a small window (about 400×640 pixels) just above the button. The page stays visible and usable around it. On phones it fills the screen. Apps where people want to vote quickly without losing their place.
inline The board is part of your page: full width of its container and as tall as its content, with no inner scrollbar and no floating button. A dedicated "Feedback", "Roadmap" or "Changelog" page on your site. Replaces the iframe embed.

Panel

<script src="https://upvotekit.com/widget.js" data-project="your-project" async></script>

The panel closes with the close button, the Escape key, or a click on the dimmed page.

Popover

<script src="https://upvotekit.com/widget.js" data-project="your-project" data-display="popover" async></script>

The popover closes with the close button, the Escape key, or a click anywhere else on your page.

Inline

Paste the tag where the board should appear:

<h1>Feedback</h1>
<script src="https://upvotekit.com/widget.js" data-project="your-project" data-display="inline" async></script>

Or point it at an element with data-target (useful when your site only lets you add scripts in one place, such as the <head> or a tag manager):

<div id="feedback"></div>
<script src="https://upvotekit.com/widget.js" data-project="your-project" data-display="inline" data-target="#feedback" async></script>

An inline board:

  • grows and shrinks with its content, so your page scrolls, not the board;
  • loads when it scrolls into view (or right away with data-load="page");
  • has no button and no open/close state. Upvotekit('open', { page: 'roadmap' }) switches its page and scrolls it into view; close and toggle do nothing.

Pick the starting page

data-page sets the page the board opens on:

data-page Opens
feedback (default) The list of requests, with voting and search
submit The "new post" form
roadmap The roadmap columns
changelog Your releases

Visitors can still move between pages with the board's own tabs.

Style the button

These apply to panel and popover:

Attribute Example Effect
data-position bottom-left Corner of the button: bottom-right (default) or bottom-left. The panel or popover opens on the same side.
data-label Ideas Text on the button. Default: Feedback.
data-color #6d28d9 Button background, as #rgb or #rrggbb. The text turns black or white automatically for contrast. Default: a neutral dark gray.
data-launcher none Hides the button. Open the board from your own elements instead (next section).

The board itself takes its look (style preset, colors, fonts, corners, light or dark) from your project's Settings → Appearance.

Open it from your own buttons

Add data-upvotekit-trigger to any element on your page. Clicking it opens the board:

<button data-upvotekit-trigger>Send feedback</button>
<a href="#" data-upvotekit-trigger="roadmap">See our roadmap</a>
  • A value names the page to open (feedback, submit, roadmap or changelog).
  • It works for elements added later too, such as menus rendered by your framework.
  • Combine with data-launcher="none" to show only your own buttons.
  • For an inline board, a trigger scrolls to the board and switches its page.

Control when the board loads

The script itself is small. The board (an iframe with your project's pages) loads only when it's needed. data-load decides when:

data-load panel and popover inline
hover (default) When a visitor points at or focuses the button or a trigger, so it's ready by the time they click When the board scrolls into view
open On the first click When the board scrolls into view
page Right after your page has finished loading Right after your page has finished loading

Use open to keep pages as light as possible, and page when the board should appear instantly.

Identify signed-in users

By default, visitors are anonymous. They can still vote, and the board asks for an email address when it needs one (for example to post). If your site has its own sign-in, identify your users so their votes and posts are linked to them, they never type their email, and you see who they are in Upvotekit.

It takes two steps: your server creates an embed token, your page passes it to the widget.

1. Create a token on your server

Call POST /api/v1/embed-tokens with an API key that has the end_users:write scope. Create the key in Developers → API keys. Never put the API key in your web page: the token is the only thing the browser should see.

curl -X POST https://upvotekit.com/api/v1/embed-tokens \
  -H "Authorization: Bearer $UPVOTEKIT_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "external_user_id": "user_123",
    "email": "[email protected]",
    "display_name": "Ada Lovelace",
    "company_external_id": "acme-inc",
    "company_name": "Acme Inc.",
    "mrr": 49.99
  }'

The response:

{ "data": { "token": "eyJhbGciOi…", "expires_at": "2026-10-01T13:00:00.000Z" } }

The same call in Node.js, for example in a route your page calls:

// GET /upvotekit-token on your server, for the signed-in user
const response = await fetch('https://upvotekit.com/api/v1/embed-tokens', {
  method: 'POST',
  headers: {
    authorization: `Bearer ${process.env.UPVOTEKIT_API_KEY}`,
    'content-type': 'application/json',
  },
  body: JSON.stringify({ external_user_id: user.id, email: user.email, display_name: user.name }),
})
const { data } = await response.json()
return { token: data.token }

Body fields:

Field Required Meaning
external_user_id yes Your own id for the user. Upvotekit matches the user by it every time.
email no The user's email; followers get status and release emails here.
display_name no Name shown next to their posts and comments.
company_external_id, company_name no The user's company. Votes are grouped by company.
mrr, company_mrr no What this user or company pays you per month, in major units (e.g. 49.99). Feeds revenue-weighted demand in your inbox; each company is counted once.
metadata no Any JSON object you want to keep with the user.
board_ids no Limit the user to these boards (up to 50).
allowed_actions no Limit what they may do: any of view, submit, vote, comment, follow. Default: all.
ttl_seconds no Lifetime of the token, 60 seconds to 24 hours. Default: 1 hour.

2. Pass the token to the widget

<script>
  window.Upvotekit=window.Upvotekit||function(){(Upvotekit.q=Upvotekit.q||[]).push(arguments)}
</script>
<script src="https://upvotekit.com/widget.js" data-project="your-project" async></script>
<script>
  fetch('/upvotekit-token')
    .then((r) => r.json())
    .then(({ token }) => Upvotekit('identify', token))
</script>

The first line is optional. It lets you call Upvotekit(…) before /widget.js has finished loading; the calls run as soon as it does.

If you render pages on the server, you can also put the token on the tag: data-token="eyJhbGciOi…".

Keep the user signed in

Tokens expire (after an hour by default). When the board notices, the widget fires auth-expired. Fetch a new token and identify again:

Upvotekit('on', 'auth-expired', async () => {
  const { token } = await fetch('/upvotekit-token').then((r) => r.json())
  Upvotekit('identify', token)
})

A new token reaches an open board without reloading it.

Sign out

Upvotekit('identify', null)

The board reloads as an anonymous visitor.

Private boards

A board set to Private (Settings → Boards) is shown only to identified users: it needs an embed token.

  • Signed-out visitors don't see private boards at all. Public boards of the same project stay visible to them.
  • Identified users see private boards, limited to board_ids if the token names any.
  • This is enforced by Upvotekit's server, not by the widget, so a visitor can't get around it.

If all of your project's boards are private, a signed-out visitor would open an empty board. Add data-require-identify="true" and the widget shows nothing (no button, no inline board, nothing loaded) until you call Upvotekit('identify', token). Upvotekit('identify', null) hides it again.

<script src="https://upvotekit.com/widget.js" data-project="your-project" data-require-identify="true" async></script>

Control it from JavaScript

Every call goes through one function, Upvotekit(command, …arguments):

Call Effect
Upvotekit('open') Opens the board (inline: scrolls to it).
Upvotekit('open', { page: 'submit' }) Opens it on a page: feedback, submit, roadmap or changelog.
Upvotekit('close') Closes the panel or popover.
Upvotekit('toggle') Opens or closes the panel or popover.
Upvotekit('identify', token) Identifies the user with an embed token.
Upvotekit('identify', null) Forgets the user.
Upvotekit('on', event, handler) Calls handler(payload) when event happens.
Upvotekit('off', event, handler) Stops calling that handler.
Upvotekit('destroy') Removes the widget from the page. Adding the script tag again starts a new one (for example with other options).

Wrong calls never break your page: they print a warning starting with [Upvotekit] in the browser console and do nothing.

Listen for events

Use Upvotekit('on', name, handler), or listen on window for a CustomEvent named upvotekit:<name> (the payload is in event.detail):

Upvotekit('on', 'feedback-created', ({ feedbackId, title }) => {
  showToast(`Thanks! We got "${title}".`)
})

window.addEventListener('upvotekit:vote-changed', (event) => {
  analytics.track('Voted', event.detail)
})
Event When Payload
ready The board has loaded { path }
open A panel or popover opened { page }
close A panel or popover closed {}
navigate The visitor moved to another page of the board { path }
resize The board's content changed height { height }
feedback-created The visitor posted a request { feedbackId, title }
vote-changed The visitor voted or took a vote back { feedbackId, voted, voteCount }
auth-expired The embed token expired or was rejected, or the board needs an identified user { code } or { reason }
unavailable The board is unavailable (for example during maintenance or after a lapsed subscription) {}
open-external Reserved for links the board wants opened outside it; not sent today {}

When the board is unavailable, the floating button hides itself. An inline board shows the board's "under maintenance" message.

Security and privacy

  • Allowed embed origins. Settings → Embed lists the sites that may show your board. If the list is empty, any site may. If it isn't, browsers block the board on other sites, and the widget prints a console warning naming the origin to add. Enter origins as scheme, host and optional port: https://www.example.com, http://localhost:3000.
  • Content Security Policy. If your site sends a CSP header, allow Upvotekit in script-src (for /widget.js) and frame-src (for the board): for example script-src 'self' https://upvotekit.com; frame-src https://upvotekit.com.
  • Tokens. Create them only on your server, keep them short-lived, and never expose your API key in the browser. A token is scoped to one project and one user and grants no admin rights.
  • Spam protection. Anonymous posts can be checked by Cloudflare Turnstile if the project has it turned on. Requests from identified users (with a token) skip it.
  • Isolation. The board runs in an iframe on Upvotekit's domain, so it can't read your page, and your page can't read it. The widget only accepts messages from that iframe.
  • No analytics. The widget doesn't track visitors or send anything to analytics services.

Recipes

Plain HTML, or any site builder

Paste the tag into your site's "custom code", "footer code" or "before </body>" field (WordPress, Webflow, Framer, Squarespace, Shopify themes all have one). For an inline board, use an "embed" or "HTML" block where the board should appear.

React

import { useEffect, useRef } from 'react'

const WIDGET = 'https://upvotekit.com/widget.js'

// <FeedbackWidget project="your-project" display="popover" token={token} />
export function FeedbackWidget({ project, display = 'panel', token }) {
  const slot = useRef(null)

  useEffect(() => {
    window.Upvotekit = window.Upvotekit || function () { (window.Upvotekit.q = window.Upvotekit.q || []).push(arguments) }
    const script = document.createElement('script')
    script.src = WIDGET
    script.async = true
    script.dataset.project = project
    script.dataset.display = display
    // An inline board appears right after its script tag, so put the tag in this component.
    ;(display === 'inline' ? slot.current : document.body).appendChild(script)
    return () => {
      window.Upvotekit?.('destroy')
      script.remove()
    }
  }, [project, display])

  useEffect(() => {
    if (token) window.Upvotekit?.('identify', token)
  }, [token])

  return <div ref={slot} />
}

Next.js

Use the React component above in a client component ('use client') that you render in your root layout for panel/popover, or on a page for inline. Fetch the token from a route handler that calls POST /api/v1/embed-tokens with the API key from your server environment.

Vue and Nuxt

<script setup>
const props = defineProps({ project: String, display: { type: String, default: 'panel' }, token: String })
const slot = ref(null)
let script

onMounted(() => {
  window.Upvotekit = window.Upvotekit || function () { (window.Upvotekit.q = window.Upvotekit.q || []).push(arguments) }
  script = document.createElement('script')
  script.src = 'https://upvotekit.com/widget.js'
  script.async = true
  script.dataset.project = props.project
  script.dataset.display = props.display
  ;(props.display === 'inline' ? slot.value : document.body).appendChild(script)
})
watch(() => props.token, (token) => token && window.Upvotekit?.('identify', token), { immediate: true })
onBeforeUnmount(() => {
  window.Upvotekit?.('destroy')
  script?.remove()
})
</script>

<template><div ref="slot" /></template>

Single-page apps

The widget lives on <body> and survives your app's route changes, so add it once in your app shell. If you add it from a component, call Upvotekit('destroy') when that component unmounts, as in the recipes above.

Google Tag Manager

Add a Custom HTML tag with the script tag and fire it on all pages. For an inline board, add data-target pointing at an element on the page, since the tag manager inserts the script elsewhere.

A feedback page with the roadmap one click away

<nav>
  <a href="#" data-upvotekit-trigger="feedback">Requests</a>
  <a href="#" data-upvotekit-trigger="roadmap">Roadmap</a>
  <a href="#" data-upvotekit-trigger="changelog">What's new</a>
</nav>
<div id="board"></div>
<script src="https://upvotekit.com/widget.js" data-project="your-project" data-display="inline" data-target="#board" async></script>

Mobile and accessibility

  • On screens narrower than 640 pixels, the panel and the popover fill the screen, and your page doesn't scroll behind them.
  • The button is a real <button> with aria-expanded. The panel and popover are dialogs with a label. When they open, focus moves to their close button; when they close, focus returns to where it was.
  • Escape closes them.
  • With "reduce motion" turned on in the operating system, nothing slides or fades.
  • The widget's own styles live in a shadow root: your CSS can't break it, and it can't change your page.

Troubleshooting

Problem Likely cause and fix
No button appears Check the browser console for [Upvotekit] warnings. Common causes: data-project is missing or misspelled, data-launcher="none" is set, or data-require-identify="true" is set and identify hasn't been called yet.
The panel opens but stays empty, and the console says "The board did not load" Your site's origin isn't in Settings → Embed → Allowed embed origins. Add it exactly as scheme and host, for example https://www.example.com.
The console shows a Content Security Policy error Allow https://upvotekit.com in script-src and frame-src.
Signed-out visitors see an empty board All your boards are private. Use data-require-identify="true", or make a board public.
Identified users still look anonymous Check that identify is called with the token string (not the whole JSON response), and that the token was created for the same project as data-project.
Users keep getting asked to sign in again The token expired. Handle auth-expired by fetching a new token (see "Keep the user signed in"), or create tokens with a longer ttl_seconds.
The inline board appears in the wrong place Without data-target, it appears right after the script tag. If the tag sits in <head>, the board goes to the end of the page: add data-target.
"The widget script is on this page twice" Remove the duplicate tag. To change options at runtime, call Upvotekit('destroy') and add a new tag.
The button hid itself The board is unavailable (maintenance or a lapsed subscription). Check your billing in Upvotekit.

Reference

Attributes

Attribute Values Default Description
data-project project slug (required) Which project's board to show.
data-display panel, popover, inline panel How the board shows.
data-target CSS selector after the script tag Inline only: the element the board goes into.
data-page feedback, submit, roadmap, changelog feedback Page the board opens on.
data-position bottom-right, bottom-left bottom-right Corner of the button.
data-label text Feedback Text on the button.
data-color #rgb or #rrggbb neutral dark Button background.
data-launcher none shown Hide the button; open with data-upvotekit-trigger or the API.
data-load hover, open, page hover When the board loads.
data-token embed token none Identify the user up front.
data-require-identify true off Show nothing until a user is identified.

Commands

open, close, toggle, identify, on, off, destroy. See "Control it from JavaScript".

Events

ready, open, close, navigate, resize, feedback-created, vote-changed, auth-expired, unavailable, open-external. See "Listen for events".

Limits

  • One widget per page, showing one project.
  • The script and the board always load from upvotekit.com, even when your public board has a custom domain.
  • There is no npm package yet; load the script tag as shown.

The plain iframe

If you'd rather size and place the board yourself, embed it directly:

<iframe src="https://upvotekit.com/embed/your-project/feedback" title="Feedback" style="width:100%;min-height:640px;border:0"></iframe>

Replace feedback with submit, roadmap or changelog for another page. Add ?token= with an embed token to identify the user. The iframe doesn't resize itself; the widget's inline mode does the same with automatic height and is the recommended way.