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
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 nothingEvents: 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.
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() yourselfA 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.