> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Add the widget to a React, Vue, Angular, or Svelte app

> Load the hosted widget script from a component that mounts once, and destroy the widget when the component unmounts

After this guide, the widget mounts once in your single-page app and disappears cleanly when the component that owns it unmounts. There is no npm package: every framework loads the same hosted script and calls `window.LuaPop.init()` once it is on the page. For plain HTML, the [quickstart](/channels/web-widget/quickstart) is enough; if you are replacing Intercom, Zendesk, or Drift, the option mapping is under [Migrating from another widget](/channels/web-widget/troubleshooting#migrating-from-another-widget).

**Before you begin**

* Your agent ID, and the widget working on a plain page per the quickstart.
* `environment: "production"` in every call: it is inferred only when admin dashboard settings are found for the domain.

<Steps>
  <Step title="Load the script and initialize once">
    Create a component that appends the script on mount, calls `init()` when it loads, and calls `window.LuaPop.destroy()` on unmount. Render it once, near the root of the app.

    <Tabs>
      <Tab title="React">
        ```tsx src/components/ChatWidget.tsx theme={null}
        import { useEffect } from "react";

        const SCRIPT_SRC = "https://lua-ai-global.github.io/lua-pop/lua-pop.umd.js";

        // sessionId: an unguessable value your backend issues per signed-in user
        export default function ChatWidget({ sessionId }: { sessionId?: string }) {
          useEffect(() => {
            const script = document.createElement("script");
            script.src = SCRIPT_SRC;
            script.onload = () => {
              void window.LuaPop?.init({
                agentId: "agent_abc123",
                environment: "production",
                sessionId,
              });
            };
            document.body.appendChild(script);

            return () => {
              window.LuaPop?.destroy();
              script.remove();
            };
          }, [sessionId]);

          return null;
        }
        ```
      </Tab>

      <Tab title="Next.js">
        ```tsx app/layout.tsx theme={null}
        import type { ReactNode } from "react";
        import Script from "next/script";

        export default function RootLayout({ children }: { children: ReactNode }) {
          return (
            <html lang="en">
              <body>
                {children}
                <Script
                  src="https://lua-ai-global.github.io/lua-pop/lua-pop.umd.js"
                  strategy="lazyOnload"
                  onLoad={() => {
                    void window.LuaPop?.init({
                      agentId: "agent_abc123",
                      environment: "production",
                    });
                  }}
                />
              </body>
            </html>
          );
        }
        ```

        `next/script` runs only in the browser, and a root layout never unmounts, so no cleanup is needed. The same `<Script>` works in `pages/_app.tsx`.
      </Tab>

      <Tab title="Vue">
        ```vue src/components/ChatWidget.vue theme={null}
        <script setup lang="ts">
        import { onMounted, onUnmounted } from "vue";

        let script: HTMLScriptElement | undefined;

        onMounted(() => {
          script = document.createElement("script");
          script.src = "https://lua-ai-global.github.io/lua-pop/lua-pop.umd.js";
          script.onload = () => {
            void window.LuaPop?.init({
              agentId: "agent_abc123",
              environment: "production",
            });
          };
          document.body.appendChild(script);
        });

        onUnmounted(() => {
          window.LuaPop?.destroy();
          script?.remove();
        });
        </script>
        ```
      </Tab>

      <Tab title="Angular">
        ```ts src/app/chat-widget.component.ts theme={null}
        import { Component, OnDestroy, OnInit } from "@angular/core";

        @Component({ selector: "app-chat-widget", standalone: true, template: "" })
        export class ChatWidgetComponent implements OnInit, OnDestroy {
          private script?: HTMLScriptElement;

          ngOnInit(): void {
            this.script = document.createElement("script");
            this.script.src = "https://lua-ai-global.github.io/lua-pop/lua-pop.umd.js";
            this.script.onload = () => {
              void window.LuaPop?.init({
                agentId: "agent_abc123",
                environment: "production",
              });
            };
            document.body.appendChild(this.script);
          }

          ngOnDestroy(): void {
            window.LuaPop?.destroy();
            this.script?.remove();
          }
        }
        ```
      </Tab>

      <Tab title="Svelte">
        ```svelte src/lib/ChatWidget.svelte theme={null}
        <script lang="ts">
          import { onMount } from "svelte";

          onMount(() => {
            const script = document.createElement("script");
            script.src = "https://lua-ai-global.github.io/lua-pop/lua-pop.umd.js";
            script.onload = () => {
              void window.LuaPop?.init({
                agentId: "agent_abc123",
                environment: "production",
              });
            };
            document.body.appendChild(script);

            return () => {
              window.LuaPop?.destroy();
              script.remove();
            };
          });
        </script>
        ```
      </Tab>
    </Tabs>

    `init()` replaces any instance that is already mounted, so a second call during hot reload is harmless.
  </Step>

  <Step title="Type the global">
    The script declares nothing for TypeScript. Add a declaration file so `window.LuaPop` type-checks; the option types match the deployed widget.

    ```ts src/types/lua-pop.d.ts theme={null}
    import type { CSSProperties } from "react"; // Without React: Record<string, string | number>

    export type LuaPopConfig = {
      authToken?: string;
      sessionId?: string;
      agentId?: string;
      position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
      draggable?: boolean;
      buttonText?: string;
      environment?: "staging" | "production" | "custom";
      theme?: "light" | "dark" | "auto";
      customBaseApiUri?: string;
      buttonColor?: string;
      buttonIcon?: string;
      chatTitle?: string;
      chatWindowHeight?: string | number;
      chatWindowWidth?: string | number;
      runtimeContext?: string;
      disablePreviewOnLinks?: boolean;
      onNavigate?: (pathname: string, options: { query: Record<string, string> }) => void;
      popupButtonStyles?: CSSProperties;
      popupButtonPositionalContainerStyles?: CSSProperties;
      chatTitleHeaderStyles?: CSSProperties;
      chatHeaderSubtitle?: {
        visible: boolean;
        brandName?: string;
        iconUrl?: string;
        linkUrl?: string;
      };
      voiceModeEnabled?: boolean;
      attachmentsEnabled?: boolean;
      microphoneEnabled?: boolean;
      chatInputPlaceholder?: string;
      displayMode?: "floating" | "embedded";
      embeddedDisplayConfig?: {
        targetContainerId: string;
        conversationStarters?: string[];
        useContainerHeight?: boolean;
      };
      welcomeMessage?: string;
      excludedPaths?: (string | RegExp)[];
    };

    export interface WidgetInstance {
      destroy: () => void;
    }

    declare global {
      interface Window {
        LuaPop?: {
          config: LuaPopConfig;
          iframe?: HTMLIFrameElement;
          init: (config?: Partial<LuaPopConfig>) => Promise<WidgetInstance | null>;
          destroy: () => void;
        };
      }
    }
    ```
  </Step>

  <Step title="Verify">
    Run the app, open a route that renders the component, then one that doesn't. The button appears on the first and is gone on the second, and `document.getElementById("lua-shadow-root")` returns `null` after the unmount.
  </Step>
</Steps>

## Options you may need

### Hide the widget on some routes

Instead of unmounting, pass `excludedPaths`; the widget removes itself when the router moves to a matching path and returns on the next `init()`.

```ts theme={null}
void window.LuaPop?.init({
  agentId: "agent_abc123",
  environment: "production",
  excludedPaths: ["/checkout", /^\/admin/],
});
```

### Keep one conversation per signed-in end user

Pass a `sessionId` your backend issues per end user and run `init()` again when the end user changes, as the React example does. The value is a bearer secret, because whoever presents it resumes the conversation, so make it unguessable: `customer-${user.id}` is not, and every new value starts a new conversation. The widget stores it under the `localStorage` key `lua_pop_session_id`, and `destroy()` doesn't clear it, so clear it on sign-out or the next end user on that browser resumes the conversation.

```js theme={null}
localStorage.removeItem("lua_pop_session_id");
window.LuaPop?.destroy();
```

How this end user appears to your tools is on [Identify users](/build/identify-users).

## Next steps

<Columns cols={2}>
  <Card title="Widget configuration" href="/channels/web-widget/configuration">Every option and what `init()` returns.</Card>
  <Card title="Troubleshoot the widget" href="/channels/web-widget/troubleshooting">When the button is missing or the chat won't open.</Card>
</Columns>
