# ask-ai-widget **"Ask AI about this page" buttons in one tag.** A row of ChatGPT, Claude, Perplexity, Gemini, Grok, Mistral and DeepSeek buttons (also called "Open in ChatGPT", "Summarize with AI" or AI share buttons). Each opens the assistant in a new tab with a prompt about the current page. No backend, no API keys, no tracking, zero dependencies. - **Any site:** one ` ``` That is the whole integration. Every button opens its service with a prompt that contains the page title and URL. For production, pin an exact version (`ask-ai-widget@0.1.0`): while the major version is 0, `@0` can receive breaking changes. Make it answer a visitor's own question instead: ```html ``` ## Let your AI coding agent do it Copy this prompt (use the copy button in the corner of the block) and paste it into Claude Code, Cursor, Copilot, Codex or any other coding agent working in your project: ```text Add the ask-ai-widget "Ask AI" button row to this project. 1. Install it with this project's package manager: npm install ask-ai-widget (For a plain HTML site with no build step, use the CDN script instead: https://cdn.jsdelivr.net/npm/ask-ai-widget@0/dist/ask-ai.min.js) 2. Read node_modules/ask-ai-widget/llms.txt before writing any code. It lists the right import for each framework, every option, and the common mistakes. 3. Detect the framework and use its entry: React/Next.js -> ask-ai-widget/react, Vue/Nuxt -> ask-ai-widget/vue, Svelte/SvelteKit -> ask-ai-widget/svelte, anything else -> the element from ask-ai-widget/element. 4. Place the widget where I ask below (or suggest a spot, e.g. under the docs page title). Use label "Ask AI about " and keep the default page context (title + URL) unless I say otherwise. 5. Prompts must be strings or synchronous functions. Never await inside them, or browsers block the new tab. 6. With SSR (Next.js, Nuxt, SvelteKit), import "ask-ai-widget/style.css" in the root layout, and pass page={{ url, title }} so server and client links match. 7. Style it only through --aai-* CSS variables or the .aai__* classes; don't copy or edit the library CSS. 8. Show me the diff and how to see it running. Where and how I want it: ``` The same guide is published at [`llms.txt`](llms.txt), so agents that read llms.txt files pick it up on their own. ## Contents - [Let your AI coding agent do it](#let-your-ai-coding-agent-do-it) - [Install](#install) - [Frameworks](#frameworks): [HTML](#html--web-component), [Vanilla JS](#vanilla-js), [React / Next.js](#react--nextjs), [Vue / Nuxt](#vue--nuxt), [Svelte](#svelte), [Astro and others](#astro-and-other-frameworks) - [Two modes: predefined prompt or user question](#two-modes) - [Prompt composition](#prompt-composition) - [Services](#services) - [Dropdown menu](#dropdown-menu) - [Label position](#label-position) - [Translations](#translations) - [Styling](#styling) - [Bundle size and tree-shaking](#bundle-size) - [Events and analytics](#events-and-analytics) - [API reference](#api-reference) - [Accessibility](#accessibility), [Privacy](#privacy), [Browser support](#browser-support) - [FAQ and troubleshooting](#faq-and-troubleshooting) - [Trademarks](#trademarks), [Contributing](#contributing), [License](#license) ## Install ```bash npm install ask-ai-widget # pnpm add ask-ai-widget # yarn add ask-ai-widget ``` React, Vue and Svelte are optional peer dependencies. You only need the one you use. The package ships ESM, CJS and `.d.ts` files for every entry. | Import | What you get | | --- | --- | | `ask-ai-widget` | Core API (`mount`, `getItems`, `buildPrompt`, ...) with all 7 services built in | | `ask-ai-widget/element` | Registers `` (side effect) and exports the element class | | `ask-ai-widget/react` | `AskAI` component | | `ask-ai-widget/vue` | `AskAI` component and `plugin` | | `ask-ai-widget/svelte` | `askAI` action | | `ask-ai-widget/style.css` | The complete stylesheet as a file, row and menu layout (optional, for zero-FOUC SSR) | | `ask-ai-widget/style-row.css` | The same without the dropdown rules, for SSR pages that only use the row layout | | `ask-ai-widget/lite`, `lite/element`, `lite/react`, `lite/vue`, `lite/svelte` | Same APIs without the built-in logos, for [tree-shaking](#bundle-size) | | `ask-ai-widget/services` | The service objects: `chatgpt`, `claude`, `perplexity`, `gemini`, `grok`, `mistral`, `deepseek`, `builtins` | | CDN `dist/ask-ai.min.js` | One file, global `AskAI`, auto-defines ``, auto-mounts `[data-ask-ai]` | ## Frameworks ### HTML / Web component ```html ``` With a bundler, `import "ask-ai-widget/element";` registers the tag instead. `` renders into its own light DOM, so your CSS applies. Configure it with attributes, with JS properties, or both (properties win): | Attribute | Property | Value | | --- | --- | --- | | `services` | `services` | comma list of keys: `"claude,chatgpt"` | | `label` | `label` | text next to the buttons | | `label-position` | `labelPosition` | `left` (default), `top`, `right`, `bottom`, `none`, see [Label position](#label-position) | | `variant` | `variant` | `soft` (default), `outline`, `solid`, `ghost`, `brand`, or your own name | | `shape` | `shape` | `circle` (default), `rounded`, `square` | | `size` | `size` | `sm`, `md` (default), `lg` | | `theme` | `theme` | `light` (default), `dark`, `auto` | | `layout` | `layout` | `row` (default) or `menu`, see [Dropdown menu](#dropdown-menu) | | `menu-align` | `menuAlign` | menu layout: `start` (default) or `end` | | `menu-icons` | `menuIcons` | menu layout: `stack` (default, logos in the trigger) or `none` | | `show-labels` | `showLabels` | boolean attribute | | `prompt` | `prompt` | template string, see [Prompt composition](#prompt-composition) | | `instructions` | `instructions` | template string placed before the prompt | | `context` | `context` | template string, or `none` / `false` to drop it | | `references` | `references` | comma list of URLs | | `template` | `template` | how the parts are joined | | `vars` | `vars` | JSON object: `vars='{"product":"Example"}'` | | `page` | `page` | JSON object: `page='{"url":"https://example.com/x","title":"X"}'` | | `max-length` | `maxLength` | number, default 4000 | | `input` | `input` | bare `input` = built-in field; `input="#q"` = existing field; `input="false"` = off | | `placeholder`, `required`, `multiline` | part of `input` | options of the built-in field (`placeholder` alone also turns the field on). With `input="#q"`, only `required` applies: `input="#q" required="false"` allows an empty question | | `strings` | `strings` | JSON object that translates the widget's own texts: `strings='{"ask":"Frage {service}"}'`, see [Translations](#translations) | | `submit-service` | `submitService` | key opened by Enter in the input | | `class-name` | (use `class`) | extra classes added to the root. `class` itself works too | | `unstyled` | `unstyled` | boolean attribute, skip the default CSS | | - | `onOpen` | function, property only | | - | `options` | `el.options = { ... }` sets several properties at once | Properties may be functions and are batched into one render: ```js const el = document.querySelector("ask-ai"); el.prompt = (ctx) => `Explain ${ctx.title} to a beginner`; el.onOpen = ({ service }) => console.log(service); ``` No JS needed for a CDN page with several widgets either: `
` is auto-mounted with the same attribute grammar using a `data-` prefix (`data-show-labels`, `data-layout="menu"`, `data-input`, ...). Call `AskAI.init()` after adding more to the DOM. ### Vanilla JS ```js import { mount } from "ask-ai-widget"; const widget = mount("#ask-ai", { services: ["claude", "chatgpt"], label: "Ask AI about this page", prompt: (ctx) => `Summarize ${ctx.title} in 3 bullets.`, onOpen: ({ service, prompt }) => console.log(service, prompt), }); widget.update({ variant: "outline" }); // merges options and re-renders widget.destroy(); // removes DOM, listeners, classes ``` `mount(target, options)` accepts an element or a selector and returns `{ element, update, destroy }`, or `null` when the target does not exist. Mounting the same element twice replaces the first instance. ### React / Next.js ```tsx import { AskAI } from "ask-ai-widget/react"; export function Share() { return ( track(service)} /> ); } ``` The component renders real `` elements on the server, forwards `ref` to the root `
`, passes `id`, `style`, `data-*` and other DOM props through, and needs no memoisation for inline function props. Extra props: `question` / `defaultQuestion` / `onQuestionChange` for the built-in input. ```tsx const [q, setQ] = useState(""); ``` **Next.js (App Router) notes** - `"use client"` is built into `ask-ai-widget/react`, so you can import it from Server Components. - Import the stylesheet once, for example in `app/layout.tsx`, so the widget is styled in the server HTML (no flash of unstyled buttons): `import "ask-ai-widget/style.css";` - Pass `page={{ url, title }}` so server-rendered hrefs equal the client's. The component re-syncs hrefs from the live document after hydration anyway, but with `page` the HTML that crawlers, middle-click and "copy link" see is already correct: ```tsx ``` - Functions cannot cross the Server to Client boundary. From a Server Component pass strings and objects only (`prompt="Explain {title}"`). If you need `prompt={(ctx) => ...}` or `onOpen`, put the widget in your own `"use client"` file. ### Vue / Nuxt ```vue ``` Register globally with the plugin: `import { plugin } from "ask-ai-widget/vue"; app.use(plugin);`. The component renders real anchors during SSR, emits `open` before a service opens and `update:question` for the built-in input, and lets `class`, `style` and `id` fall through. Use `show-labels` or `:show-labels="true"` for `showLabels`. For Nuxt, pass `:page="{ url, title }"` to make server and client hrefs identical. ### Svelte ```svelte
``` Works in Svelte 3, 4 and 5. Updating the options object re-renders; keys you remove fall back to their defaults. An action runs in the browser, so the server sends an empty `
`. ### Astro and other frameworks Anything that can output HTML can use the element. Astro: ```astro ``` In Angular add `CUSTOM_ELEMENTS_SCHEMA`; in Solid, Lit, Alpine or plain templates just write the tag. Booleans are attributes (`show-labels`), objects are JSON attributes or element properties. ## Two modes **Predefined prompt (default).** Clicking a button opens the service with a fixed prompt, by default `Explain this page.` plus the page context. **User question.** Add `input` and the visitor types what they want to know. The default prompt becomes `{question}`. ```html ``` ```js mount("#w", { input: true }); // built-in field mount("#w", { input: { placeholder: "Ask...", multiline: true } }); // textarea, required defaults to true mount("#w", { input: "#q" }); // existing field, by selector mount("#w", { input: document.querySelector("#q") }); // existing field, by element mount("#w", { input: { target: "#q", required: false } }); // existing field, empty question allowed ``` Behaviour: - Clicking any button opens that service with the typed question. In this mode the buttons are the field's submit action: they always sit directly to the right of the field (in the menu layout, the trigger does), on one row with the label. On narrow containers (about 440px and below) the label moves above and the field and buttons stay together on one row below it; below about 400px the buttons shrink to the `sm` size. The field follows `variant`, `shape`, `size` and `theme` like the buttons (soft: same fill; outline: same border; solid: same fill with light text; ghost: transparent with a faint border). - **Enter** in the field opens `submitService` (default: the first service). In a textarea, **Shift+Enter** inserts a new line. IME composition is respected. - `required` (default `true`) blocks opening when the question is empty: the field gets `aria-invalid="true"` and focus. Set `input: { required: false }` on the built-in field to allow empty questions. For an existing field use `input: { target: "#q", required: false }` (element attribute: `input="#q" required="false"`, CDN auto-mount: `data-input="#q" data-required="false"`); a bare selector or element is always required. - Hover and keyboard focus refresh the buttons' `href` with the current question, so middle-click and "copy link address" carry it. - Combine with a custom prompt: `prompt: "Answer using this page. Question: {question}"`. ## Prompt composition The text sent is assembled from four parts and a template. | Part | Option | Default | | --- | --- | --- | | Instructions | `instructions` | none | | Prompt | `prompt` | `{question}` with `input`, otherwise `Explain this page.` | | Context | `context` | `Context: {title} — {url}` (`Context: {url}` if there is no title). `false` disables | | References | `references` | none. Renders as `Use these sources:` and one `- url` line per entry | They are joined with the `template`, default `{instructions}\n\n{prompt}\n\n{context}\n\n{references}`. Empty parts vanish and runs of blank lines collapse. **Placeholders** work in `prompt`, `instructions`, `context` and `template`: | Placeholder | Value | | --- | --- | | `{question}` | what the visitor typed (empty without `input`) | | `{url}` | `page.url`, else `location.href` | | `{title}` | `page.title`, else `document.title` | | `{description}` | `page.description`, else `` | | `{selection}` | the text the visitor has selected on the page | | `{service}` | the key of the service being opened | | `{anything}` | any key of `vars` | Unknown placeholders become an empty string, so a literal `{curly}` in your text disappears. Built-in names win over a `vars` entry of the same name. In `template` you can also use `{instructions}`, `{prompt}`, `{context}` and `{references}`. **Strings or functions.** Any part can be a function of the context. It runs synchronously at click time, and its return value is used as is (no placeholder expansion): ```js mount("#w", { prompt: (ctx) => `Compare ${ctx.title} with the alternatives.`, references: (ctx) => ["/llms.txt", ctx.url], }); ``` `ctx` is `{ service, question, url, title, description, selection, vars }`. **Relative references** resolve against the page URL (`page.url`, else `location.href`), are de-duplicated, and empty entries are dropped. **`maxLength`** (default 4000 characters) keeps URLs manageable. When the text is too long it is shortened in this order: context (with an ellipsis), then references one line at a time from the end, then instructions, and only then the prompt itself. A final hard cut never splits a surrogate pair. ### Worked example ```js mount("#w", { services: ["claude", "gemini"], instructions: "You are a helpful {product} support engineer. Answer concisely.", prompt: "Explain this page to a beginner.", references: ["/llms.txt", "/docs/api"], vars: { product: "Example" }, page: { url: "https://example.com/docs/quickstart", title: "Quickstart | Docs" }, }); ``` The text sent to Claude (`https://claude.ai/new?q=` + URL-encoded text): ```text You are a helpful Example support engineer. Answer concisely. Explain this page to a beginner. Context: Quickstart | Docs — https://example.com/docs/quickstart Use these sources: - https://example.com/llms.txt - https://example.com/docs/api ``` Preview any configuration without clicking: `AskAI.buildPrompt(opts, AskAI.getContext(opts, question, serviceKey))` (the same functions are exported from `ask-ai-widget`), or use the [playground](https://acler.cz/ask-ai/playground). ## Services | Key | Label | URL (prompt goes in `?q=`) | Prefill | | --- | --- | --- | --- | | `chatgpt` | ChatGPT | `https://chatgpt.com/` | yes, auto-submits (verified Oct 2026) | | `claude` | Claude | `https://claude.ai/new` | yes; logged-out users go through sign-in and the prompt is kept | | `perplexity` | Perplexity | `https://www.perplexity.ai/search` | yes, auto-submits (verified Oct 2026) | | `gemini` | Gemini | `https://gemini.google.com/app` | **no**: copied to clipboard | | `grok` | Grok | `https://grok.com/` | yes, Grok asks the user to confirm sending (verified Oct 2026) | | `mistral` | Mistral | `https://chat.mistral.ai/chat` | yes, **unverified** | | `deepseek` | DeepSeek | `https://chat.deepseek.com/` | **no**: copied to clipboard | - **Order matters.** `services` renders in the order you give; omit it for all seven in the table order. Keys are case-insensitive. Unknown keys are dropped with a console warning. - **Gemini and DeepSeek** have no reliable URL prefill. The widget copies the prompt to the clipboard, shows "Prompt copied — paste it into Gemini" in a polite live region for four seconds, and opens the site. The `?q=` parameter is still appended in case the service starts honouring it. Clipboard access needs HTTPS (a secure context); without it the site opens with no hint. - **Mistral's** `?q=` comes from third-party documentation and has not been verified by this project. It is treated as prefilled. If it does not work for you, override it: `registerService({ ...mistral, prefill: false })`. - URL formats of third-party products change without notice. If one breaks, override it with `registerService` until a release catches up. ### Custom services Add a service for the whole page with `registerService`, or pass an object inline: ```js import { mount, registerService } from "ask-ai-widget"; registerService({ key: "kagi", label: "Kagi", url: "https://kagi.com/assistant", param: "q", icon: "M12 2 L22 22 H2 Z", // path data for a 24x24 viewBox, filled with currentColor color: "#FFB319", // used by variant="brand" }); mount("#w", { services: ["claude", "kagi"] }); mount("#w", { services: ["claude", { key: "me", label: "My AI", url: "https://ai.example.com/", param: "prompt" }] }); ``` `ServiceDef` is `{ key, label, url, param, icon?, color?, prefill? }`. Set `prefill: false` for services that ignore the parameter to get the copy-to-clipboard behaviour. A registered service with a built-in's key replaces it. **Icon fallback.** A service without an `icon` renders the first character of its label in the circle (emoji-safe). With `showLabels` the letter is followed by the service name, like every other service (React, Vue and `mount` render the same). ## Dropdown menu `layout: "menu"` replaces the row of logos with one **Ask AI** button. Clicking it opens a menu that lists every service (logo and name); choosing one does exactly what clicking its button in the row does: the required-question check, the clipboard fallback and hint for Gemini and DeepSeek, `onOpen`, the `ask-ai:open` event and `window.open`, all synchronous. Middle-click and Ctrl/Cmd-click on an item keep the browser's native link behaviour. ```html
``` ```js import { mount } from "ask-ai-widget"; mount("#w", { layout: "menu", label: "Ask AI", menuAlign: "end" }); ``` ```tsx import { AskAI } from "ask-ai-widget/react"; ``` ```vue ``` ```svelte
``` | Option | Default | Notes | | --- | --- | --- | | `layout` | `"row"` | `"menu"` turns on the dropdown. Attribute `layout`, CDN `data-layout` | | `label` | `strings.menu`, else `"Ask AI"` | In the menu layout `label` is the trigger text (it is not rendered as a separate label). Also the accessible name of the menu | | `strings.menu` | `"Ask AI"` | Default trigger text when no `label` is set, so translated pages need no `label` | | `menuAlign` | `"start"` | `"end"` lines the menu up with the right edge of the trigger. Attribute `menu-align` | | `menuIcons` | `"stack"` | `"stack"` shows up to three overlapping logos (the first services that have an icon) in the trigger; `"none"` shows only the label and chevron. Attribute `menu-icons` | Menu items always show the service name. `showLabels` and `strings.ask` do not apply to them (the visible name is the accessible name). The built-in input still works: the field is rendered next to the trigger, and choosing a service with an empty required question closes the menu and focuses the field. **Keyboard.** On the trigger, ArrowDown, Enter or Space open the menu and focus the first item; ArrowUp opens it and focuses the last. In the menu, ArrowDown and ArrowUp move with wrap-around, Home and End jump to the ends, a letter jumps to the next item starting with it, Space or Enter activates, Escape closes and returns focus to the trigger, and Tab closes without trapping focus. A press outside the widget closes the menu. The menu is a `role="menu"` list of `role="menuitem"` links opened by a `
``` **Styling.** `variant`, `shape`, `size` and `theme` apply to the trigger like to the buttons, and the `--aai-bg`, `--aai-bg-hover`, `--aai-fg`, `--aai-border`, `--aai-radius`, `--aai-shadow` and `--aai-btn-size` properties work on it. `variant="brand"` colors the logos in the stack and in the menu. The panel has its own properties: | Property | Default (light / dark) | Purpose | | --- | --- | --- | | `--aai-menu-bg` | `#fff` / `#1c1c1e` | panel background | | `--aai-menu-fg` | `#1a1a1a` / `#f2f2f2` | item text color | | `--aai-menu-border` | `#d4d4d9` / `#46464c` | panel border color | | `--aai-menu-shadow` | `0 8px 24px rgba(0,0,0,.18)` | panel shadow | | `--aai-menu-radius` | `min(radius, 14px)` | panel radius (items use `min(radius, 10px)`) | | `--aai-menu-z` | `1000` | z-index | Hovered rows use `--aai-bg-hover` if set, else `--aai-tint`; the keyboard-focused row shows the `--aai-focus` ring. The panel is at least 200px wide. With `prefers-reduced-motion` the transitions are off. ## Label position `label` is the text next to the buttons. `labelPosition` decides where it sits: `left` (default), `top` (own line above), `right` (after the buttons) or `bottom` (own line below). `none` renders no visible text, yet the label stays the accessible name of the button group, so screen readers still announce "Ask AI about this page". ```html
``` ```js mount("#w", { label: "Ask AI about this page", labelPosition: "right" }); // React / Vue: the labelPosition prop ``` The label is always the first element in the DOM (screen readers read it before the buttons); only CSS moves it, and the root gets `data-label-position`. With the built-in question field the field and the buttons always stay together on one row, buttons to the right of the field, and the label goes above or below that row, or after the buttons for `right`. In containers narrower than about 440px a `left` or `right` label moves above, so the field and the buttons keep one row. The menu layout has no separate label (it is the trigger text), so `labelPosition` is ignored there. ## Translations The widget writes four texts itself. Override any of them with `strings`; `{service}` is replaced by the service label. Omitted keys keep the English default. | Key | Used for | Default | | --- | --- | --- | | `ask` | `aria-label` and `title` of each button | `Ask {service}` | | `question` | `aria-label` of the built-in question field | `Your question` | | `copied` | hint after the prompt was copied (Gemini, DeepSeek) | `Prompt copied — paste it into {service}` | | `menu` | trigger text of the [dropdown menu](#dropdown-menu) when no `label` is set | `Ask AI` | ```js mount("#w", { input: true, label: "Frag die KI", strings: { ask: "{service} fragen", question: "Deine Frage", copied: "Kopiert. Füge es in {service} ein." } }); ``` ```html ``` React and Vue take `strings` as a prop (`:strings` in Vue), Svelte as a key of the action options, the CDN auto-mount as `data-strings='{...}'`. The accessible name of the button group is your `label`; in the menu layout the trigger text is your `label`, else `strings.menu`. ## Styling The widget injects one `