@criclabs/universal-floating-contact-button
v1.0.3
Published
Embeddable, framework-agnostic floating contact widget (Phone, LINE, Messenger, and more) built with Lit + Tailwind CSS, shipped as a single standalone script.
Readme
Universal Floating Contact Button
A tiny, embeddable "floating contact button" — the classic bottom-right bubble
that expands into Phone / LINE / Messenger / WhatsApp / etc. buttons. Ships as
one standalone <script> file (dist/widget.v1.js) that any website can
drop in, with zero build step or framework dependency required on the host
site (plain HTML, WordPress, Shopify, Webflow, LCS, whatever).
Built with Lit (Web Components + Shadow DOM) and Tailwind CSS compiled inline, bundled by Vite into a single IIFE.
Why Shadow DOM?
The widget renders inside a Shadow Root, so:
- The host site's global CSS can never restyle/break the widget (no more
button { border-radius: 0 !important }fights). - The widget's own styles never leak out and affect the host page.
See index.html for a dev page that intentionally ships hostile global CSS
to prove this isolation.
Project structure
├── src/
│ ├── components/widget.ts # <contact-widget-element> — the Lit component
│ ├── assets/icons/ # Heroicons, Remix, and Tabler SVG variants
│ ├── icon-variants.ts # Maps widget/button types to variant SVG assets
│ ├── icons.ts # Inline fallback renderers and close icon
│ ├── button-presets.ts # Per-channel default colors/labels/icons
│ ├── types.ts # WidgetConfig / ButtonConfig public types
│ ├── utils/link-resolver.ts # Turns phone/LINE-ID/FB-URL shorthand into real links
│ ├── index.ts # SDK entry point — mounts the element + processes the command queue
│ ├── styles.css # Tailwind directives (compiled + inlined into the Shadow Root)
│ └── vite-env.d.ts
├── index.html # Dev-only test/demo page
├── vite.config.ts # Builds a single dist/widget.v1.js IIFE
├── .github/workflows/
│ └── publish-package.yml # Publishes tagged releases to npm
├── tailwind.config.js
├── postcss.config.js
└── tsconfig.jsonGetting started
npm install
npm run dev # http://localhost:5173 — live dev/test page
npm run build # -> dist/widget.v1.js (single file, ready to deploy/CDN)
npm run typecheck # tsc --noEmitDistribution via jsDelivr
The built widget is published as part of the
@criclabs/universal-floating-contact-button npm package.
jsDelivr mirrors public npm packages automatically, so no S3 bucket,
CloudFront distribution, deploy credentials, or cache invalidation is needed.
The package includes dist/widget.v1.js, with its default CSS and icons
already bundled into that file.
Use a major-version URL in production so compatible v1 updates are picked up without unexpectedly upgrading to a future breaking release:
https://cdn.jsdelivr.net/npm/@criclabs/universal-floating-contact-button@1/dist/widget.v1.jsFor a release that must never change, pin the exact package version instead,
for example @criclabs/[email protected].
Release process
Releases move from develop to main through a dedicated release PR. npm
Trusted Publishing must be configured for this repository and
publish-package.yml; no long-lived npm token is required.
Update
develop, then create a release branch named for the version being prepared:git checkout develop git pull git checkout -b release/v1.0.1Choose the appropriate semantic version increment and update the package metadata without creating a Git tag:
npm version patch --no-git-tag-version # or minor / major git add package.json package-lock.json git commit -m "Release v1.0.1" git push -u origin release/v1.0.1Open a PR from
release/v1.0.1tomaintitledRelease v1.0.1. Complete the normal review and merge process.Merging a
release/v*PR intomaintriggerspublish-package.yml. The workflow verifies that the branch name matches the version inpackage.json, type-checks and builds the widget, tags the merge commit, publishes it to npm, and fast-forwardsdevelopto the released commit. jsDelivr then mirrors the new package version automatically.The repository must provide a
RELEASE_TOKENActions secret for an admin user. The token needs repository contents write access so the workflow can create the tag and bypassdevelop's pull-request requirement. npm itself continues to use Trusted Publishing and does not require an npm token.Verify the release on npm and at the version-pinned CDN URL:
https://cdn.jsdelivr.net/npm/@criclabs/[email protected]/dist/widget.v1.js
The workflow still supports manually pushed v* tags as a recovery path. A
manual tag must match the version in package.json; it publishes the package
but does not update develop.
Published npm versions are immutable. If a release needs a correction, create a new patch version rather than attempting to replace the existing version.
Embedding on any website
Paste this snippet before </body> on any page:
<script>
(function (w, d) {
if (w.ContactWidget) return;
var q = [];
function ContactWidget() {
q.push(arguments);
}
ContactWidget.q = q;
w.ContactWidget = ContactWidget;
var s = d.createElement('script');
s.async = true;
s.src = 'https://cdn.jsdelivr.net/npm/@criclabs/universal-floating-contact-button@1/dist/widget.v1.js';
d.body.appendChild(s);
})(window, document);
ContactWidget('init', {
color: '#EA1C24',
iconVariant: 'heroicon',
label: 'Contact us',
buttons: [
{ type: 'phone', value: '0869828288' },
{ type: 'line', value: '@LCSGROUP' },
{ type: 'messenger', value: 'https://www.facebook.com/lcsgroupthailand' },
],
});
</script>Because calls are queued into ContactWidget.q until the real script
finishes downloading, the init call can appear immediately after the
loader (as above) — there's no race condition, and it works the same way
whether the widget script has loaded yet or not. This is the same pattern
used by Intercom, Google Analytics, Crisp, etc.
Real example — LCS Group Thailand
This is the exact config used in index.html to test the widget with real
production contact info:
ContactWidget('init', {
color: '#EA1C24',
iconVariant: 'heroicon',
label: 'ติดต่อเรา',
buttons: [
{ type: 'phone', value: '0869828288', label: 'โทรเลย' },
{ type: 'line', value: '@LCSGROUP', label: 'แชททาง LINE' },
{ type: 'messenger', value: 'https://www.facebook.com/lcsgroupthailand', label: 'Messenger' },
],
});Note that the Messenger button's value is the Facebook Page URL — the
widget automatically rewrites facebook.com/<page> into the correct
https://m.me/<page> deep link so the button opens a Messenger chat, not
the Facebook page itself.
Configuration reference
WidgetConfig
| Property | Type | Default | Description |
| ---------------- | ----------------------------- | ----------------- | -------------------------------------------------------------------- |
| color | string (CSS color) | #EA1C24 | Main button color. |
| iconVariant | 'heroicon' \| 'remix' \| 'tabler' | 'heroicon' | Built-in icon set for the toggle and channel buttons. |
| buttonSize | number (px) | 48 | Diameter of the main toggle and inner channel buttons. |
| position | 'bottom-right' \| 'bottom-left' | 'bottom-right' | Which corner the widget docks to. |
| offsetX | number (px) | 32 | Horizontal distance from the viewport edge. |
| offsetY | number (px) | 32 | Vertical distance from the viewport edge. |
| pulse | boolean | false | Animated attention-grabbing ring around the closed button. |
| pulseScale | number | 2.4 | Maximum pulse size relative to the circular button. |
| pulseDuration | number (ms) | 1800 | Duration of each pulse cycle. |
| label | string | — | Tooltip pill shown next to the closed button (e.g. "Contact us"). |
| showLabels | boolean | false | Always show each button's label as a pill instead of only on hover. |
| autoOpenDelay | number (ms) | — | Auto-expand the menu this many ms after the widget mounts. |
| zIndex | number | 999999 | Stacking order override. |
| buttons | ButtonConfig[] | required | The channel buttons to render, top to bottom. |
Icon variants
Set iconVariant at widget level to use a consistent icon family, then set
buttons[].iconVariant when one button needs a different family. A per-button
icon URL still takes priority over both variant settings. The close icon is
shared across variants.
| Icon | Heroicons (heroicon) | Remix (remix) | Tabler (tabler) |
| --- | --- | --- | --- |
| Widget toggle | |
|
|
| Phone |
|
|
|
| LINE |
|
|
|
| Messenger |
|
|
|
| Facebook |
|
|
|
| WhatsApp |
|
|
|
| Telegram |
|
|
|
| Instagram |
|
|
|
| TikTok |
|
|
|
| LinkedIn |
|
|
|
| WeChat |
|
|
|
| Map |
|
|
|
| Form / custom |
|
|
|
| Email |
|
|
|
Messenger, Telegram, and TikTok use their recognizable brand marks across all three families because those logos do not have library-specific variants.
ButtonConfig
| Property | Type | Description |
| --------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| type | 'phone' \| 'line' \| 'messenger' \| 'facebook' \| 'whatsapp' \| 'telegram' \| 'instagram' \| 'tiktok' \| 'linkedin' \| 'wechat' \| 'map' \| 'form' \| 'email' \| 'custom' | Which channel. Controls the default icon/color/link format. |
| value | string | A shorthand (phone number, LINE ID, username) or a full URL — see below. |
| label | string | Accessible label + tooltip text. Falls back to a sensible per-type default. |
| color | string | Override the built-in brand color/gradient for this button. |
| icon | string (URL) | Override the built-in icon with your own image (PNG/SVG/CDN link). |
| iconVariant | 'heroicon' \| 'remix' \| 'tabler' | Override the widget-level icon family for this button only. |
| target | '_blank' \| '_self' | Defaults to '_blank'. |
| enabled | boolean | Set false to temporarily hide a button without deleting its config. |
Shorthand → link resolution
You can always paste an already-complete URL/URI, but each channel also accepts a shorthand so non-technical teams (content/marketing) can paste what they already have:
| type | Shorthand example | Resolves to |
| ----------- | -------------------------------------------- | ------------------------------------------------- |
| phone | "0869828288" | tel:0869828288 |
| email | "[email protected]" | mailto:[email protected] |
| line | "@LCSGROUP" | https://line.me/R/ti/p/%40LCSGROUP |
| messenger | "https://www.facebook.com/lcsgroupthailand" | https://m.me/lcsgroupthailand |
| facebook | "lcsgroupthailand" | https://www.facebook.com/lcsgroupthailand |
| whatsapp | "66869828288" | https://wa.me/66869828288 |
| telegram | "lcsgroup" | https://t.me/lcsgroup |
| instagram | "lcsgroup" | https://www.instagram.com/lcsgroup |
| tiktok | "lcsgroup" | https://www.tiktok.com/@lcsgroup |
| linkedin | "criclabs" | https://www.linkedin.com/in/criclabs |
| wechat | "@criclabs" | weixin://dl/chat?criclabs |
| map | "Bangkok, Thailand" | Google Maps search URL |
| form | any full URL | used as-is |
| custom | any full URL | used as-is (uses the selected form icon by default) |
Runtime commands
Once mounted, window.ContactWidget(command, payload) supports:
ContactWidget('init', config)— mount (or re-mount) the widget.ContactWidget('update', partialConfig)— merge new config into the live widget (e.g. re-theme per page/section).ContactWidget('open')/ContactWidget('close')— control the menu programmatically.ContactWidget('destroy')— unmount the widget entirely.
The element also dispatches cw-open, cw-close, and cw-button-click
DOM events (bubbling, composed) if you want to hook up analytics.
Design notes
- Single source of truth for links:
src/utils/link-resolver.tscentralizes every "shorthand → real URI" rule, so adding a new channel type only requires one resolver + one entry inbutton-presets.ts. - Icon variants: the widget bundles Heroicons, Remix, and Tabler SVG sets and selects them through
iconVariant. Any button can still override its icon with a custom image URL. - Animation: menu items cascade in/out (
animate-cw-pop-in/animate-cw-pop-out, staggered by proximity to the trigger button) and the trigger icon cross-fades between the chat bubble and the closeX. - Accessibility: every link has an
aria-label/title, the trigger button exposesaria-expanded,Escapecloses the menu, and clicking outside the widget closes it too.
