@demoloop/react

中文文档
2.2.0发布于 2026/11/08MITTypeScriptESM + CJS零依赖近 30 天下载 2865

React components for embedding Demoloop AI demos.

原始使用文档
文档目录21 节
  1. Install
  2. Usage
  3. The microphone and your Permissions-Policy
  4. Components
  5. DemoloopProvider
  6. DemoloopFloatingOrb
  7. DemoloopButton
  8. DemoloopSection
  9. DemoloopLiveSection
  10. DemoloopPreviewSection
  11. DemoloopEmbed
  12. useDemoloop()
  13. One script, every demo
  14. Content-Security-Policy
  15. Surfaces and modes
  16. Copy
  17. Tracking
  18. Styling and theming
  19. Upgrading from 1.x
  20. Upgrading from 0.x
  21. License

@demoloop/react

React components for embedding Demoloop AI demos in your site.

Drop in a provider, a floating CTA, an inline button, or a full Meet-style hero section — all wired to the same start card, which Demoloop's own script opens over your page.

🔗 Live demo → react-demo.demoloop.ai — every component on one page.

Install

npm install @demoloop/react

Peer dependencies: react >=18, react-dom >=18. No other runtime deps — no Tailwind, no framer-motion, no icon library to install. The components ship their own plain CSS and SVG icons, inject them into <head> once on first mount, and stay out of your design system's way.

Usage

Wrap your app (or any subtree) with DemoloopProvider, then place any of the buttons or sections inside it.

import {
  DemoloopProvider,
  DemoloopFloatingOrb,
  DemoloopButton,
  DemoloopSection,
} from "@demoloop/react";

export default function Layout({ children }) {
  return (
    <DemoloopProvider agentId="agt_xxx">
      {children}
      <DemoloopFloatingOrb />
    </DemoloopProvider>
  );
}

That's it — no CSS imports, no Tailwind content-path configuration, no plugin wiring.

The microphone and your Permissions-Policy

The demo runs in a frame from https://app.demoloop.ai, and the visitor talks to it, so that frame needs the microphone. Demoloop's script and DemoloopEmbed delegate it themselves (allow="microphone …" on the frame). Most sites need nothing else.

If your site sends a Permissions-Policy header (or <meta http-equiv>) that mentions microphone, its allowlist must include both your own origin and ours:

Permissions-Policy: microphone=(self "https://app.demoloop.ai")

self is the part that is easy to leave out, and leaving it out breaks the demo silently. A frame only gets a feature that the page around it is allowed too. With microphone=("https://app.demoloop.ai"), your page is not allowed the microphone, so neither is the demo inside it. The browser then refuses without ever showing a prompt, and the demo tells the visitor the page will not let it ask (they can still type in the chat). microphone=() blocks it the same way. A header with no microphone entry is fine: the default already allows your page, and the iframe's allow passes it on.

Components

DemoloopProvider

Holds the offer (where demos may run, and what for) and exposes open() / close() to all children.

PropTypeDefaultDescription
agentIdstring—The agent. One agent per page. With none, triggers render but open() does nothing.
baseUrlstringhttps://app.demoloop.aiOverride for self-hosting. A trailing /d (what 0.x asked for) is ignored.
copyDemoloopCopyInputEnglishEvery string the SDK renders, as one typed object. See Copy.
theme"light" | "dark""dark"Look of the SDK's own triggers. The start card follows your page.
lang"en-US" | "es-ES" | string—The language the start card opens in (the visitor can change it there). Without it, your page's <html lang>.
emailstring—Who the visitor is, when your app knows. With it the card does not ask for an email. Unverified, as a typed address is.
namestring—Their name, passed on with email (and only with it), so the agent never asks for it.
surfaces("call" | "in-app")[]["call"]Where demos may run, in the order to offer them. See Surfaces and modes.
modes("tour" | "help")[]["tour"]What demos may be for, in the order to offer them.
featurestring—Default focused feature id: tours are narrowed to that feature.
onOpen(info: DemoloopOpenInfo) => void—Called every time a trigger opens the start card. See Tracking.
loadScript"mount" | "open""mount"When Demoloop's script loads. "open" loads it only once a demo is started (or one is already running in the tab), or as a DemoloopFloatingOrb mounts, since the script draws it. See One script, every demo.

DemoloopFloatingOrb

Floating "AI aura" orb pinned to a bottom corner — a glowing, morphing gradient ring around the agent avatar (à la ElevenLabs). Clicking it opens Demoloop's start card.

Demoloop's script draws it (window.Demoloop.orb), in a frame of its own over your page, beside the start card and the call. The component renders nothing itself; it tells the script what the orb should be. A frame is a separate document, so the orb keeps working while your app has a dialog or sheet open: a modal's pointer-events: none, its "click outside closes it" and its focus trap never reach it, and neither does your CSS. One orb per page; a second one replaces the first. It steps out of the way while the start card or a demo is up.

PropTypeDefault
position"right-bottom" | "left-bottom""right-bottom"
labelstring"Talk to our AI" ("Need help?" when it offers help only)
content"avatar" | "sparkles" | "orb""avatar"
surfaces("call" | "in-app")[]the provider's
modes("tour" | "help")[]the provider's
featurestringthe provider's
draggablebooleantrue
persistPositionbooleantrue
dismissiblebooleantrue
closeLabelstring"Hide for now"
bottomnumber (px)0

Visitors can move the orb to the other bottom corner when it covers something they need: dragged, it follows the pointer and settles in the nearer corner; with keyboard focus, the left and right arrow keys move it. A drag never triggers the click, so the demo only opens on a real tap. The start card and the demo open on the orb's side, so they never appear in the other corner. position is only where it starts. The corner a visitor picks is remembered in localStorage, scoped to the agent; set persistPosition={false} to forget it on the next page load, or draggable={false} to pin the orb in place.

A small close button appears on the orb on hover or focus (always visible on touch, where there is no hover). Clicking it hides the orb for that browser session only — the dismissal lives in sessionStorage, scoped to the agent and corner, so the orb is back on the visitor's next visit or in a new tab. Pass dismissible={false} to make the orb un-closeable, and closeLabel to translate the button's accessible label.

The label shows as a tooltip on hover/focus. Your CSS cannot move it, so if your app has a bar along the bottom of the screen (a phone's tab bar), pass its height as bottom and the orb keeps that much room below it. Set content="orb" for the pure living-gradient sphere with no avatar or icon. Set modes={["help"]} to turn the orb into a floating customer-support bubble, or pass a feature id to open a targeted single-feature tour — see Surfaces and modes.

DemoloopButton

Drop-in inline button. Accepts standard <button> attributes plus children, className, showIcon. With no children it reads copy.inlineButton.label ("See it live").

DemoloopSection

Full-bleed section with a blurred Meet-style backdrop. Renders the centered title + subtitle as the foreground composition.

PropTypeDefault
titlestring"You ask. It shows you."
subtitlestring"An AI agent uses the product while you watch. Pick a feature, change your mind, interrupt anytime. It adapts."
buttonLabelstring"Start the demo"

DemoloopLiveSection

Mock of the actual live-call view as a marketing section. Headlines / surrounding copy are intentionally not part of this component — wrap it in your own section markup.

PropTypeDefault
buttonLabelstring"Join the live demo"
chromeLabelstring"live demo"
agentNamestring"Demo agent"

DemoloopPreviewSection

Centered card with a browser-chrome strip, agent avatar, tagline, and a "Start the demo" button. Headlines / surrounding copy are intentionally not part of this component — wrap it in your own section markup.

PropTypeDefault
displayNamestring"Your demo agent"
taglinestring"Will share a website and talk with you live"
buttonLabelstring"Start the demo"
chromeLabelstring"Live demo session"
showChromebooleantrue

DemoloopEmbed

Inline live-demo iframe of the agent's hosted start page — no overlay. Use this when you want the demo to live directly in a page rather than behind a CTA.

PropTypeDefaultDescription
aspectRatiostring"16/10"CSS aspect ratio. Ignored when height is set.
heightstring | number—Explicit height (number is px). Useful for full-bleed embeds.
framedbooleantrueWraps the iframe in a themed rounded card with ring and shadow.
titlestring"Demo"Iframe accessibility title.

Until the provider has an agentId the card renders empty, with no iframe. The iframe's url carries iframe=1, which tells the demo it is embedded so it lays itself out edge to edge.

<DemoloopProvider agentId="agt_xxx">
  <DemoloopEmbed aspectRatio="16/9" />
</DemoloopProvider>

useDemoloop()

Hook returning { agentId, url, avatarUrl, theme, surfaces, modes, open, close }. Use it to build your own trigger. url is the agent's hosted start page as a link (what DemoloopEmbed frames, and what a plain <a> can point at): it carries lang, email, theme and feature when set, and mode=support when the first mode is "help".

open(options?):

  • Opens Demoloop's start card for the provider's surfaces and modes, unless options override them for that click. It asks the visitor only what is left open, and goes straight in when there is nothing to choose: one surface, one mode and a known email start the call at once.
  • options is { source?: string; surfaces?: DemoloopSurface[]; modes?: DemoloopMode[]; feature?: string }. source is passed to the provider's onOpen callback (defaults to "custom", see Tracking).
  • Returns a Promise<boolean>: true once the open is handed to the page's script, false when it could not be (no agent, the script would not load, or a close() came first). It never rejects, so a trigger that ignores it is fine; a custom one can say when it did not open.
  • There is no agentId argument: Demoloop's script runs one agent per page.

close() takes the card, or the demo running, off the page. Call it when the user signs out, too: unmounting the provider does not end a running demo, on purpose, since your app's own navigation unmounts it.

One script, every demo

Every demo, a call over your page or the agent in your visitor's own tab, opens through one script: Demoloop's https://app.demoloop.ai/embed/v1.js. DemoloopProvider loads it (from your baseUrl's origin, once per page) and every trigger calls its window.Demoloop.open({ surfaces, modes, feature, lang }), which shows Demoloop's start card in a frame over your page. The SDK draws no modal of its own.

  • Mount the provider on every page of your app, not only where a trigger is: the script is what brings a running demo back after a reload or a full-page navigation.
  • loadScript="open" keeps the script off your pages until it is used: it loads on the first open, or at mount when a demo is already running in this tab (so a reload still rejoins it). Nothing of Demoloop's loads for a visitor who never starts a demo, unless the page has a DemoloopFloatingOrb: the script draws it, so it loads as the orb mounts. The trade-off: Demoloop's install check (the Deploy page) only hears from browsers that started one.
  • email and name are passed on as who the visitor is (name only with an email), so they are not asked for either. They are read when the open reaches the script, so props that arrive while it loads still count. Without an email the provider identifies nobody, and whatever your app told the script directly (window.Demoloop.identify) stays.
  • One agent per page. The script runs the first agent it loaded for; a provider for another agent does not switch it (the console says so once).
  • onOpen fires once the open was handed to the script, never for a script that failed to load.
  • A click is answered at once, even before the script is up. Since 2.2.0, an open while the script is still loading (a click in a page's first second, or on a slow connection) draws the script's own "Connecting…" tile right away: the dimmed page and a tile in the middle for a call, the corner card otherwise, with the agent's face, in the language the card opens in. The script takes over in the same place the moment it is up. If it never loads, the tile says so with a Try again. Over a script already up, the SDK draws nothing: the script shows its tile itself.

Content-Security-Policy

If your page sends a CSP, it must allow https://app.demoloop.ai in script-src (the script), frame-src (the start card and the demo are frames) and connect-src (the script reports to it). A blocked script (a CSP without them, or an ad blocker) shows the visitor the "Couldn't load" tile and logs the error; Try again, or the next open, loads it afresh.

script-src 'self' https://app.demoloop.ai;
frame-src 'self' https://app.demoloop.ai;
connect-src 'self' https://app.demoloop.ai;

Add those sources to the directives you already have; the lines above only show the Demoloop part. The microphone needs the Permissions-Policy described above.

Surfaces and modes

An open offers two things, both set on the provider and overridable per trigger or per click:

  • surfaces: where the demo runs.
    • "call" (default): the agent shares its screen on your demo account, on a call over your page. Runs anywhere.
    • "in-app": the agent in the visitor's own signed-in tab, on their real account, with a small call widget over the page. Only where in-app is turned on for the agent and your app's address is listed in Demoloop (the exact origin, https://app.example.com; list each subdomain). Elsewhere it is left out of what the visitor is offered. The agent works your app as the visitor would, so it can do anything they can: what it may not do is set with the agent's guardrails in Demoloop.
  • modes: what the demo is for.
    • "tour" (default): a guided walkthrough of the product.
    • "help": a reactive helpdesk, where the agent answers the visitor's questions instead of running a tour.

Several values are offered in the order given, and the card asks the visitor to pick. One surface, one mode and an email leave nothing to ask, so a call starts at once.

// A support bubble for the signed-in app
<DemoloopProvider agentId="agt_xxx" surfaces={["in-app"]} modes={["help"]} email={user.email} name={user.name}>
  {children}
  <DemoloopFloatingOrb /> {/* label defaults to "Need help?" */}
</DemoloopProvider>

Per trigger, over the provider's:

<DemoloopProvider agentId="agt_xxx">
  {children}
  {/* A targeted single-feature tour */}
  <DemoloopFloatingOrb feature="wbf_xxx" label="See this feature" />
</DemoloopProvider>
// Your own trigger, opting into a mode for this click
const { open } = useDemoloop();
<button onClick={() => open({ source: "pricing_help", modes: ["help"] })}>
  Ask a question
</button>

A feature narrows a tour to one feature (or page): it is the id of a feature in Demoloop, perfect for a link you drop straight into an email. A help session ignores it.

Copy

Every string the SDK renders lives in one typed object, DemoloopCopy. Pass any subset as copy on the provider; the rest stays English.

import { DemoloopProvider, type DemoloopCopyInput } from "@demoloop/react";

const es: DemoloopCopyInput = {
  floatingOrb: { label: "Habla con la IA", moveHint: "muévelo con las flechas" },
};

<DemoloopProvider agentId="agt_xxx" copy={es}>

The groups are floatingOrb, inlineButton, section, liveSection, previewSection and embed. The start card and the call are Demoloop's own, in the language the card opens in (lang), so there is no group for them. Because the type is closed, a translation typed as the whole DemoloopCopy is one the compiler checks: every string you have not translated is an error you see, rather than a stray English word you find in production. That includes strings a new version adds, so an upgrade tells you what is left to translate:

import type { DemoloopCopy } from "@demoloop/react";

// Every group, every key. Leave one out and this does not compile.
const es: DemoloopCopy = {
  floatingOrb: { label: "Habla con la IA", /* … */ },
  // …
};

Spreading defaultCopy into it ({ ...defaultCopy, floatingOrb: { ...defaultCopy.floatingOrb, close: "Cerrar" } }) fills every hole with English and switches that check off. That is fine for a deliberate partial translation, which DemoloopCopyInput already covers.

Individual components still take their own label props, and those win for that one instance — two inline buttons with different labels on one page is a layout decision, not a language. useDemoloopCopy() returns the resolved copy if you are building your own trigger.

Tracking

DemoloopProvider accepts an onOpen callback that fires every time the start card is opened, whatever opened it: one of the SDK's triggers, the orb (whose click Demoloop's script handles itself), or your own open(). Demoloop's script announces every open with a demoloop:open event on window ({ source, surfaces, modes, feature }), and onOpen is reported from that event, so a page using the script tag directly can listen for the same event. It receives a { source, agentId, surfaces, modes, feature } info object (what was offered, in order; agentId is the agent the page's script actually runs) so you can wire analytics in one place:

<DemoloopProvider
  agentId="agt_xxx"
  onOpen={({ source, agentId }) => {
    posthog.capture("demo_opened", { source, agentId });
  }}
>
  {children}
</DemoloopProvider>

Built-in source values:

Componentsource
DemoloopFloatingOrb"floating_orb"
DemoloopLiveSection"live_section"
DemoloopSection"section"
DemoloopPreviewSection"preview_section"
DemoloopButton"inline_button"
useDemoloop().open()"custom"

When calling open() directly from your own button, you can supply a more specific source:

const { open } = useDemoloop();
<button onClick={() => open({ source: "hero_button" })}>
  Live demo
</button>

Styling and theming

All styles are plain CSS scoped under dl-* class names, injected into <head> once on first mount. To customize, override the class rules in your own stylesheet — they have low specificity (single-class selectors), so any rule loaded after our injected <style data-demoloop> will win. Theme variants are selected via [data-theme="light"] / [data-theme="dark"].

Since 0.8.0 nothing of the SDK's animates a property the browser has to repaint or lay out on every frame, and nothing animates where nobody can see it:

  • The live section's CTA glow and the join control's glow are clipped, blurred pills with static cones spinning inside them (.dl-aura-spin, two in the CTA, one in the join control). They used to turn an @property angle inside conic-gradient() and morph border-radius, which re-rastered and re-blurred them 60 times a second. The join glow only spins while hovered.
  • The faux cursor is a full-size layer moved with transform (it used to animate top/left).
  • The speaking tile's pulse is an ::after ring whose opacity pulses (it used to animate box-shadow).
  • The CTA and join buttons have darker fills and no backdrop-filter.
  • The live section marks itself data-dl-offscreen while scrolled away, and everything in it holds still.
  • Reduced motion stops every looping animation.

On an idle page the live section went from about 2s of main-thread work per 10s to 0.2s on screen and nothing off screen. Hooks that no longer do anything: --dl-aura-angle (gone), .dl-live-section__cta-aura::before (moves to .dl-aura-spin--counter), and a background on .dl-live-section__cta-aura or .dl-tile__join-aura (the colours are on .dl-aura-spin now, inside a .dl-aura-clip). The speaking tile's ring is drawn inside the tile, so it lost its outer halo. Under reduced motion the ping and pulse halos are hidden rather than resting as discs.

Upgrading from 1.x

2.0 hands the floating orb to Demoloop's script, so it works over your app's own dialogs (see DemoloopFloatingOrb).

  • DemoloopFloatingButton is gone, with its copy.floatingButton, DemoloopPosition, the "floating_button" source and its .dl-floating-cta* classes. Use DemoloopFloatingOrb, or DemoloopButton / useDemoloop().open() in your own layout.
  • DemoloopFloatingOrb draws nothing in your page. Its className prop and the .dl-floating-orb* classes are gone: the orb lives in Demoloop's frame, out of reach of page CSS. A closed orb stays closed (the same storage key).
  • The orb snaps to a bottom corner rather than staying wherever it was dropped, and the card and demo open on its side. A 1.x orb that was dragged across the page comes back once on the side it was dragged towards.
  • onOpen is reported from the script's demoloop:open event, so it fires for the orb's own clicks too. The provider tells the script who the visitor is whenever email or name change, not only on a click.
  • The orb needs the script. A mounted orb loads it, whatever loadScript says, and a page whose CSP blocks the script shows no orb.

Upgrading from 0.x

1.0 removes the SDK's own modal: every demo opens through Demoloop's script and its start card (see One script, every demo). Most embeds need only the renames below.

0.x1.0
surface="hosted" (default)surfaces={["call"]} (default)
surface="in-app"surfaces={["in-app"]}
mode="demo"modes={["tour"]} (default)
mode="support"modes={["help"]}
open(agentId, { source, feature, mode })open({ source, feature, modes }): one agent per page, so no agentId argument
useDemoloop().surface / .modeuseDemoloop().surfaces / .modes
onOpen({ source, agentId, mode })onOpen({ source, agentId, surfaces, modes, feature })
baseUrl="https://app.demoloop.ai/d"baseUrl="https://app.demoloop.ai" (the old value still works)
  • The modal is gone, with everything of it: allowFullscreen, confirmFullscreen, confirmClose, onClose, copy.modal, the .dl-modal-* classes and the data-dl-modal-open attribute. Demoloop's card does its own loading, closing and leaving, in the language set by lang.
  • DemoloopEmbed and the url from useDemoloop() still frame / link the agent's hosted start page.
  • Your CSP must allow https://app.demoloop.ai in script-src, frame-src and connect-src: a hosted-only 0.x embed needed only frame-src.
  • loadScript and name now apply to every demo, not just in-app ones.

License

MIT

版本与发布记录

@demoloop/react 共发布 32 个版本,首次发布于 2026/06/18;过去 12 个月发布了 32 个版本。

版本发布日期距今
2.2.0latest2026/11/084 天前
2.1.02026/11/038 天前
2.0.02026/11/038 天前
1.0.02026/10/3013 天前
0.9.02026/10/2023 天前
0.8.12026/10/2023 天前

关于 @demoloop/react 的常见问题

@demoloop/react 是什么?

React components for embedding Demoloop AI demos.

如何安装 @demoloop/react?

在项目目录运行 npm install @demoloop/react,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(>=18)、react-dom(>=18)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@demoloop/react 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@demoloop/react 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@demoloop/react 可以商用吗?

@demoloop/react 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@demoloop/react 的下载量有多少?

根据 npm 官方下载统计,@demoloop/react 在 2026/10/12 – 2026/11/10 这 30 天内被下载 2,865 次,日均约 96 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@demoloop/react 还在维护吗?

最新版本 2.2.0 发布于 2026/11/08(4 天前);至今共发布 32 个版本,其中过去 12 个月发布了 32 个;首次发布于 2026/06/18。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。