npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@askelo/react

v0.1.1

Published

React components and hooks for the Askelo support widget.

Downloads

286

Readme

@askelo/react

The Askelo support widget, as React components and a hook.

npm install @askelo/react

Quick start

Render it once, near the root of your app. Nothing else is required — the launcher, the panel and everything they look like come from your widget's settings in the dashboard.

import { AskeloWidget } from "@askelo/react";

export default function App() {
  return (
    <>
      <YourApp />
      <AskeloWidget widgetId="your-widget-id" />
    </>
  );
}

Your widget id is in Channels → Widget in the dashboard. Add the origin your app runs on to that widget's allow-list, or the widget will refuse to start (error.code === "bootstrap_failed").

Controlling the widget

Wrap the part of your app that needs control in <AskeloProvider> and use useAskelo():

import { AskeloProvider, useAskelo } from "@askelo/react";

function SupportLink() {
  const { open, unreadCount, status } = useAskelo();
  return (
    <button onClick={open} disabled={status !== "ready"}>
      Support{unreadCount > 0 ? ` (${unreadCount})` : ""}
    </button>
  );
}

export default function App() {
  return (
    <AskeloProvider widgetId="your-widget-id">
      <YourApp />
      <SupportLink />
    </AskeloProvider>
  );
}

<AskeloWidget> is <AskeloProvider> with no children, so moving from one to the other is a rename.

Your own launcher

Pass hideLauncher and the floating bubble never appears — along with the greeting bubble and unread badge that hang off it. Your trigger becomes the only way in, and the unreadCount from useAskelo() is how you badge it.

<AskeloProvider widgetId="your-widget-id" hideLauncher>
  <YourApp />
</AskeloProvider>

<AskeloLauncher> is an unstyled <button> that already handles the parts that are easy to get wrong — aria-expanded, aria-haspopup, an accessible name that announces unread replies without overriding your visible text, and a disabled state while the widget loads:

<AskeloLauncher className="your-button-styles">
  {({ unreadCount }) => <>Support {unreadCount > 0 && <Badge>{unreadCount}</Badge>}</>}
</AskeloLauncher>

Toggling hideLauncher after mount shows or hides the launcher in place. It never reloads the widget, so it will not interrupt a conversation.

Styling

Two surfaces, customized two ways, because they live in two different places.

The chrome — CSS on your own page

The launcher, the floating card and the greeting bubble are rendered in your DOM, so plain CSS reaches them. Set these custom properties anywhere they cascade — :root, a wrapper, a media query, your dark-mode class:

:root {
  --askelo-launcher-size: 72px;
  --askelo-launcher-offset-y: 120px;   /* clear of your cookie banner */
  --askelo-panel-radius: 4px;
  --askelo-z-index: 500;               /* put your own modal above the widget */
}

| Property | Default | What it does | | --- | --- | --- | | --askelo-launcher-size | 60px (54px under 768px) | Launcher width and height | | --askelo-launcher-offset-x | 24px (20px under 768px) | Distance from the left/right edge | | --askelo-launcher-offset-y | 24px (20px under 768px) | Distance from the top/bottom edge | | --askelo-launcher-radius | 50% | Launcher corner radius | | --askelo-launcher-background | gradient from your brand colour | Launcher fill | | --askelo-launcher-shadow | two-layer soft shadow | Launcher shadow | | --askelo-panel-width | 420px (fills the screen under 768px) | Floating panel width | | --askelo-panel-height | 640px (fills the screen under 768px) | Floating panel height | | --askelo-panel-offset | 104px | Gap between the panel and the viewport edge | | --askelo-panel-radius | 20px (16px under 768px) | Panel corner radius | | --askelo-panel-shadow | two-layer deep shadow | Panel shadow | | --askelo-greeting-width | 320px | Greeting bubble width | | --askelo-greeting-background | white | Greeting bubble fill | | --askelo-greeting-radius | 16px | Greeting bubble corner radius | | --askelo-greeting-shadow | soft shadow + hairline | Greeting bubble shadow | | --askelo-z-index | 2147483000 | Base of the widget's stack — launcher +2, panel +1, greeting -1 |

Set a property once and it applies at every breakpoint: the responsive defaults only apply when you have not set one. A launcher you sized at 72px stays 72px on a phone, because that is what you asked for.

The embedded chat's own box is yours entirely — className and style on <AskeloChat> are your element:

<AskeloChat widgetId="…" className="h-[70vh] rounded-2xl border shadow-sm" />

The panel — the theme prop

The panel is a cross-origin iframe, so no CSS of yours reaches inside it. Three values cross the boundary instead:

<AskeloProvider
  widgetId="your-widget-id"
  theme={{
    accent: "#ff0055",
    fontFamily: '"Söhne", system-ui, sans-serif',
    colorScheme: resolvedTheme,   // "light" | "dark" | "auto"
  }}
>
  <YourApp />
</AskeloProvider>

| Key | Default | Notes | | --- | --- | --- | | accent | your dashboard setting | Overrides it for this page only | | fontFamily | the platform UI stack | Nothing is downloaded — name a face your app already loads | | colorScheme | "auto" | "light" / "dark" force it; "auto" follows the visitor's OS |

Changing the prop re-themes in place — it never reloads the widget and never closes an open conversation, which matters because the moment it changes is usually the moment someone hit your theme toggle mid-chat. An inline object is fine: only its contents are compared.

Wiring it to next-themes is the common case:

const { resolvedTheme } = useTheme();
return (
  <AskeloProvider widgetId="…" theme={{ colorScheme: resolvedTheme === "dark" ? "dark" : "light" }}>
    {children}
  </AskeloProvider>
);

The same three values can come from CSS instead, which is what a plain <script> tag install uses — they are read off your root element when the widget loads. CSS is read once, at load; the prop applies at any time and wins over it.

The two accents are separate on purpose. --askelo-accent themes the panel's interior; --askelo-launcher-background themes the launcher. Setting your brand colour in the dashboard drives both, which is what nearly everyone wants — reach for these only when one surface has to differ from the other on a particular page.

What is not themeable, and why

Spacing, radii and per-element colours inside the panel are deliberately not exposed. Everything that crosses the iframe boundary becomes an API we have to keep working, and the panel's interior already sits inside a box whose outside you control completely. If you need something specific there, ask — that is a better conversation than a token nobody can ever change.

A chat on your own page

Sometimes the bubble in the corner is the wrong shape — a dedicated support page, a help sidebar, an in-app Help tab. <AskeloChat> draws the same widget as a block on your page:

import { AskeloChat } from "@askelo/react";

export default function SupportPage() {
  return (
    <main>
      <h1>Support</h1>
      <AskeloChat widgetId="your-widget-id" style={{ height: 640 }} />
    </main>
  );
}

Give it a height. The chat fills its container and brings no size of its own, so a container without one renders a box of nothing. style={{ height }} or a className both work — if you pass a className, sizing is entirely yours and no default is applied.

It is the same widget and the same conversation. Someone who asks a question on your support page and later opens the launcher from your pricing page is in one conversation, and the agent who picks it up sees all of it.

Rendered on its own, <AskeloChat> provides its own widget and hides the floating launcher (pass showLauncher to keep it). Rendered inside an existing <AskeloProvider>, it uses that one — so an app that already mounts the launcher does not end up with two widgets.

<AskeloProvider widgetId="your-widget-id">
  <YourApp />                        {/* launcher, everywhere */}
  <Route path="/support"><AskeloChat /></Route>  {/* and inline, here */}
</AskeloProvider>

One thing worth knowing: an embedded chat reports the visitor as present only while it is actually on screen. Presence decides whether an agent's reply is also emailed, and a chat scrolled off the page is not being read — so scrolling away means the reply reaches the visitor's inbox too.

Telling the widget who your visitor is

If your app already knows who is signed in, pass them. The widget stops asking them to type their own email address to reach a human, and the agent who picks the conversation up sees their name.

<AskeloProvider widgetId="your-widget-id" user={{ externalId: user.id, email: user.email, name: user.name }}>
  <YourApp />
</AskeloProvider>

Changing the prop re-identifies. It never reloads the widget and never closes an open conversation — which matters most at exactly the moment it changes, since someone logging in mid-chat is the ordinary case. An inline object is fine: only its contents are compared, so a re-render costs nothing.

From anywhere under the provider, useAskelo().identify(user) does the same thing — for a login callback, or after a profile edit changes the name.

An identity is a prefill and a label. It fills in the contact details on a support case and names the person to your agent. It is not authentication and it never widens what the widget can see.

Verified identities

Because that prop is rendered in a browser, by itself it is a claim: anyone who can open the page can make it. Turn on identity verification for your widget (Channels → Widget → your widget → Identity verification) and Askelo will only accept a claim your server has signed.

// on your server — e.g. the endpoint your app already calls on login
import { createHmac } from "node:crypto";

const subject = user.id ?? user.email;          // externalId first, else email
const signature = createHmac("sha256", process.env.ASKELO_IDENTITY_SECRET)
  .update(subject)
  .digest("hex");

return { externalId: user.id, email: user.email, name: user.name, signature };
<AskeloProvider widgetId="your-widget-id" user={identity}>
  <YourApp />
</AskeloProvider>

The contract is HMAC-SHA256(key = your widget's secret, message = subject), hex-encoded and lowercase, where subject is the externalId — or the email when there is no externalId. Compute it on the server: a secret your frontend can read is a secret any visitor can read.

Only that one field is proven. With an externalId, email rides beside it unsigned — it prefills the case, but it is not trusted for anything an action reads, like a Stripe or Shopify lookup scoped to the visitor's verified email.

Proving both fields at once (v2)

If you need externalId and email trusted together, sign the versioned message instead and send signatureVersion: 2 alongside:

// on your server
import { createHmac } from "node:crypto";

function v2Message(externalId: string, email: string) {
  const normalizedEmail = email.trim().toLowerCase();
  return `v2\nuid:${externalId.length}:${externalId}\nemail:${normalizedEmail.length}:${normalizedEmail}`;
}

const signature = createHmac("sha256", process.env.ASKELO_IDENTITY_SECRET)
  .update(v2Message(user.id, user.email))
  .digest("hex");

return { externalId: user.id, email: user.email, name: user.name, signature, signatureVersion: 2 };
{/* identical shape — signatureVersion just rides along */}
<AskeloProvider widgetId="your-widget-id" user={identity}>
  <YourApp />
</AskeloProvider>

Both fields are length-prefixed in the message so no value either one can contain — a newline, a colon — can shift where uid ends and email begins. email is trimmed and lowercased before it enters the message; sign it the same way on your server. Existing integrations that sign only externalId or only email keep working unchanged — v2 is opt-in per identify call, not a widget-wide setting.

Once a secret is set, an unsigned or wrongly signed identity is refused, not recorded as unverified. So deploy your signing code first, or turn verification on after your integration is live. Until you set one, identities are accepted and your agents see them marked as unverified.

Next.js

The components are client components ("use client"). In the App Router, render them from a client component or a client boundary:

// app/providers.tsx
"use client";
import { AskeloProvider } from "@askelo/react";

export function Providers({ children }: { children: React.ReactNode }) {
  return <AskeloProvider widgetId={process.env.NEXT_PUBLIC_ASKELO_WIDGET_ID!}>{children}</AskeloProvider>;
}

Nothing loads during SSR. The widget is fetched from an effect after hydration, so it adds nothing to your server-rendered HTML.

API

<AskeloProvider> / <AskeloWidget>

| Prop | Type | Description | | --- | --- | --- | | widgetId | string | Required. From your dashboard. | | cdnUrl | string | Only if you were given a dedicated CDN origin. Defaults to Askelo's. | | hideLauncher | boolean | Suppress the built-in launcher and drive the panel yourself. | | timeoutMs | number | How long to wait before failing. Default 20000; 0 waits forever. | | user | AskeloVisitor | Who the visitor is. Changing it re-identifies; it never reloads the widget. | | theme | AskeloTheme | Panel accent, font and colour scheme. Changing it re-themes in place. See Styling. | | enabled | boolean | Default true. Set false for a consent gate — flipping it tears the widget down. | | onLoad | (widget) => void | Called once with the handle when the widget is ready. | | onError | (error: AskeloError) => void | Called if it never comes up. |

<AskeloChat>

Everything <AskeloProvider> takes except hideLauncher, plus:

| Prop | Type | Description | | --- | --- | --- | | showLauncher | boolean | Keep the floating bubble alongside the embedded chat. Off by default. | | className | string | Sizing is yours when you pass one — no default height is applied. | | style | CSSProperties | Defaults to height: 640 when you set neither this nor className. |

widgetId is required unless there is an <AskeloProvider> above it.

useAskelo()

const {
  status,      // "idle" | "loading" | "ready" | "error"
  error,       // AskeloError | null
  isOpen,      // whether the panel is on screen
  unreadCount, // unread agent replies since the panel was last open
  widget,      // the underlying handle, or null
  open, close, toggle,
  identify,    // (user: AskeloVisitor) => Promise<void>
} = useAskelo();

open, close and toggle are safe to call before the widget is ready — the call is dropped rather than queued, so a visitor who clicks during a slow load and navigates away does not get a panel opening itself on the next page.

identify is the exception: an early call is queued and sent once the widget is up. A host app that knows its user faster than the CDN answers should not have to forget who they are.

Errors

onError and error receive an AskeloError with a code:

| Code | Meaning | | --- | --- | | invalid_widget_id | widgetId was empty or not a string. | | invalid_cdn_url | cdnUrl was set but was not an http(s) URL. | | script_load_failed | The loader script could not be fetched. | | bootstrap_failed | The widget did not start — usually a wrong id, or an origin that is not on the allow-list. | | timeout | Nothing came back within timeoutMs. | | no_browser | Called outside a browser. |

Notes

  • One widget per page. Mounting two providers for the same widget is harmless — they share one widget, torn down when the last unmounts — and if the page also carries the old <script> snippet, the package adopts the running widget rather than adding a second.
  • React 18 and 19, and StrictMode's double mount, are supported.
  • Vanilla JS, or another framework? @askelo/browser is the layer underneath this one.