Add I Want To to your site

One script and one line of code. The tab appears on your page; the panel loads the first time someone hovers over the tab, focuses it or opens it.

Quick start

Paste this before the closing body tag. Replace the project ID with yours (Widget & install in the console).

<script src="https://iwanto.org/widget/widget.js" charset="utf-8" async
  onload="window.iwanto = new IWantTo({ projectId: 'YOUR_PROJECT_ID' })"></script>

charset="utf-8" is required: it keeps non-English texts from being garbled on pages that do not declare their encoding.

Strict CSP

Under a policy without 'unsafe-inline' the inline onload is blocked. Load the file with defer, then construct from your own script.

<script src="https://iwanto.org/widget/widget.js" charset="utf-8" defer></script>
<script src="/js/iwanto-init.js" defer></script>

// /js/iwanto-init.js — deferred scripts run in order, after widget.js
window.iwanto = new IWantTo({ projectId: 'YOUR_PROJECT_ID' });

Before you start

Register your site’s origin. Matching is exact: scheme, host and port. https://www.example.com is not https://example.com, and http://localhost:8080 is not http://localhost:3000. From an unregistered origin every API call answers 403: the tab shows, and the panel says it could not load.
Content Security Policy. If your site sends one, allow the service host for scripts and for connections. With attachments, also the bucket origin and blob: images. No style exception is needed.
No cookies. The sign-in token is kept in localStorage on your page; the widget sets no cookies on your domain.
script-src  https://iwanto.org
connect-src https://iwanto.org
# with attachments also:
connect-src https://<your-bucket-origin>
img-src     blob:

React, Vue, Next.js

Load widget.js once, in your HTML, not from a component. Create the widget once and destroy it when its part of the app unmounts. A second instance replaces the first, with a console warning.

React

import { useEffect } from 'react';

export function IWantToWidget({ projectId }: { projectId: string }) {
  useEffect(() => {
    const iwanto = new window.IWantTo({ projectId });
    return () => iwanto.destroy();
  }, [projectId]);
  return null;
}

Vue 3

import { onMounted, onBeforeUnmount } from 'vue';
let iwanto;
onMounted(() => { iwanto = new window.IWantTo({ projectId: 'YOUR_PROJECT_ID' }); });
onBeforeUnmount(() => iwanto?.destroy());

Next.js (app router)

'use client';
import Script from 'next/script';
import { useEffect, useRef } from 'react';

export function IWantToWidget({ projectId }: { projectId: string }) {
  const instance = useRef<IWantTo | null>(null);
  useEffect(() => () => instance.current?.destroy(), []);
  return (
    <Script
      src="https://iwanto.org/widget/widget.js"
      strategy="afterInteractive"
      onReady={() => { instance.current ??= new window.IWantTo({ projectId }); }}
    />
  );
}

Options

The console writes the project ID, side and tab label into the snippet. The accent colour is saved with the project and used by the widget and the public board; passing accentColor in code overrides it for the widget only. Everything else is set in code. A value of the wrong type falls back to its default; an unknown key is ignored with a console warning.

projectIdrequiredThe project ID from the console.
accentColorproject accent (saved in the console)Buttons and active states. #hex, rgb() or hsl(). Text on it is picked for contrast.
accentTextColorby contrastText and icons on the accent colour. #hex, rgb() or hsl(). Empty: white or dark, picked for contrast.
tabColorthe accentThe tab’s background. Same formats as accentColor.
tabTextColorby contrastThe tab’s text: white or near-black, whichever reads better.
position'right''right' or 'left': the edge of the tab; on phones, the side of the round button.
tabLabel"I Want To …"Up to 24 characters, plain text. Without it: the label set in the console, else “I Want To …” in the visitor’s language.
tabBorderWidth0Outline around the tab in pixels, 0 to 4. 0 means none.
tabBorderColorautoColour of that outline; used only when tabBorderWidth is above 0. Auto is a translucent shade of the tab text colour, so it shows on light and dark pages.
tabVertical'middle' | 'top' | 'bottom' | number 0–100'top' (20%), 'middle' (50%), 'bottom' (80%) or any number from 0 to 100, in percent of the viewport height, on desktop.
tabPeek8How many pixels of the tab show at the screen edge on desktop before the visitor hovers it, 0 to 38.
offsetnoneThe tab’s top in pixels from the top of the viewport, instead of tabVertical.
mobileOffset14Distance of the phone button from the bottom, px. The safe area is added.
tabIcon'iwanto''iwanto' (the mark) or 'bubble'.
theme'auto''auto' follows the visitor’s system; or 'light' / 'dark'.
radius'default' | 'sharp' | 'round' | number 0–2'default', 'sharp' or 'round', or a number from 0 (square) to 2 (very round) that scales every rounded corner; 1 is the default.
fontFamily'system''system' or 'inherit' (your page’s font).
hideTabfalseNo tab. Open with open() or trigger.
closeOnOutsideClicktrueClose the panel on a click or tap outside it. The × and Esc always work. On by default; set `false` to keep the panel open while visitors use your page.
triggernoneA CSS selector. A click on a match opens the panel, including elements added later.
localenegotiated'en' or 'uk'. Otherwise the browser language, the project default, then English.
defaultTypenoneA type id the project has enabled, preselected on the compose flow’s type step. Ignored otherwise, or when only one type is enabled.
defaultCategoryIdnoneA category preselected on the category step, if the project has it.
apiBasescript originAn http(s) URL to call instead, e.g. a regional API.
zIndex2147483647Lower it to keep your own overlays above the widget.
usernone{ email, name } only prefills the sign-in form. It never signs anyone in.
metanoneUp to 50 keys, stored with each item and visible to your team.
onBeforeSendnoneAn async hook awaited before a new item is sent. Return { meta } to add data. See Methods and events.

Methods and events

The widget does nothing until you construct it. Events carry plain data only: no DOM nodes, no tokens.

const iwanto = new IWantTo({ projectId: 'YOUR_PROJECT_ID' });

iwanto.open();          // open the panel
iwanto.close();         // close it
iwanto.toggle();
iwanto.isOpen;          // boolean

iwanto.update({ position: 'left', theme: 'dark' });   // applies live
iwanto.update({ locale: 'uk' });                      // projectId, apiBase and locale re-create the widget

const off = iwanto.on('submit', ({ id }) => console.log('created', id));
off();                                                   // or iwanto.off('submit', handler)

iwanto.destroy();       // removes the widget, its listeners and timers; later calls do nothing

Events: open, close, signin, submit, vote and comment. Each is also available as a callback option: onOpen, onClose, onSignIn, onSubmit, onVote, onComment.

Before send

onBeforeSend is an option, not an event. It is awaited right before every new item is sent, may be async, and lets you add data at that moment. It cannot be subscribed to with on(), and it is not called for votes or comments.

new IWantTo({
  projectId: 'YOUR_PROJECT_ID',
  onBeforeSend: async ({ type, title, body, categoryId, rating, meta }) => {
    return { meta: { plan: 'pro' } };   // or return nothing
  },
});

It receives a copy of the item as plain data: { type, title, body?, categoryId?, rating?, meta }, where meta is the current value of the meta option.

Return { meta: { key: value } } to add or overwrite keys. Return nothing to change nothing.
Return { files: [File, …] } to attach files. They are always private to your team, even if the visitor made theirs public. They are added only when the type accepts attachments, and count toward the limit of 5 files, 5 MB each (PNG, JPG, GIF, WebP, PDF, TXT, LOG, MD or ZIP).
Same limits as meta. 50 keys in total; a key up to 32 characters of A-Z a-z 0-9 _ . -; a value is a string up to 200 characters, a number or a boolean. Invalid entries are dropped with a console warning.
Runs on every attempt, including the retry after the visitor signs in.
Never blocks feedback. If the handler throws, rejects or does not answer within 5 seconds, the item is sent with the static meta and one warning is logged.
Not verified. The values are set by your page, so a visitor could change them. Same caveat as meta.

Recipes

Your own launcher button

new IWantTo({ projectId: 'YOUR_PROJECT_ID', hideTab: true, trigger: '[data-iwanto]' });
// <button data-iwanto>Send feedback</button>  — or call iwanto.open() yourself

A signed-in user of your app

This only prefills the form. They still confirm their email with a code.

new IWantTo({
  projectId: 'YOUR_PROJECT_ID',
  user: { email: me.email, name: me.name },
  meta: { plan: me.plan }
});

Add data right before sending

new IWantTo({
  projectId: 'YOUR_PROJECT_ID',
  meta: { auth: 'google' },
  onBeforeSend: async ({ type, meta }) => {
    const me = await fetch('/api/me').then((r) => r.json());
    return { meta: { plan: me.plan, license: me.licenseId, seats: me.seats } };
  },
});

The team sees auth, plan, license and seats next to the item in the console.

Attach the browser console log to a problem report

const logs = [];
['log', 'warn', 'error'].forEach((k) => {
  const orig = console[k];
  console[k] = (...a) => { logs.push('[' + k + '] ' + a.join(' ')); orig(...a); };
});

new IWantTo({
  projectId: 'YOUR_PROJECT_ID',
  onBeforeSend: async ({ type }) => {
    if (type !== 'problem') return;
    const file = new File([logs.join('\n')], 'console.txt', { type: 'text/plain' });
    return { files: [file] };
  },
});

The file is private to your team and appears in the item’s attachments as auto-collected. Only attach logs you have checked for personal data.

Analytics

const iwanto = new IWantTo({ projectId: 'YOUR_PROJECT_ID' });
iwanto.on('submit', ({ id }) => analytics.track('item_created', { id }));
iwanto.on('vote', ({ id, value }) => analytics.track(value === 1 ? 'agreed' : 'unagreed', { id }));

Mobile apps

Android apps can show the same feedback panel with a small library: download one file, add it to Gradle and call one function. Other platforms are not supported yet.

  • The app needs only the project ID. It uses the shared origin https://mobile.iwanto.org, so there is nothing to register.
  • The Android app is off by default: turn it on per project in the console (Widget & install › Mobile app).

API reference

Build your own client against the same API the widget uses. Every operation, request and response is described in an OpenAPI file.

  • Reading needs no sign-in; writing needs a bearer token from the email-code sign-in.
  • Every request must come from a site registered in the console (Widget & install). Native apps: see the Android guide below.
  • Every operation can answer 429 with a Retry-After header.
Stuck?
Your project ID, the allowed origins and a ready snippet are in the console, on Widget & install.
Open the console ›