# 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.

## 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:

   ```html
   <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

```html
<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

```html
<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:

```html
<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):

```html
<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:

```html
<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.

```bash
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": "ada@example.com",
    "display_name": "Ada Lovelace",
    "company_external_id": "acme-inc",
    "company_name": "Acme Inc.",
    "mrr": 49.99
  }'
```

The response:

```json
{ "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:

```js
// 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

```html
<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:

```js
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

```js
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.

```html
<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`):

```js
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

```jsx
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

```vue
<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

```html
<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:

```html
<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.
