Your designer sends a screenshot of the new landing page with a red circle around the bottom-right corner. A green chat bubble is sitting on top of the "Start free trial" button on mobile, and on the pricing page it covers the annual/monthly toggle. Somebody suggests moving it to the left. Somebody else points out that the left is where the cookie banner lives.
The real question is not where the bubble goes. It is which pages should have one at all.
Where a chat bubble earns its place
Yes: the app itself. Someone who is signed in and stuck is exactly who support exists for, and a bubble is the fastest route to help.
Yes: documentation and pricing, sometimes. A pre-sales question is worth answering, especially for higher-value plans. Measure it rather than assuming: count conversations started from those pages and how many became customers.
No: the landing page hero. A floating element in the corner competes with the single action the page exists to produce, and the people it catches are mostly asking questions the page should have answered.
No: sign-in and sign-up. A bubble here invites "I can't log in" as a support conversation instead of a password reset the user could do in ten seconds, and those conversations are unverifiable by definition.
No: checkout. Anything that can be clicked instead of the pay button costs you money.
The mistake: hiding instead of not loading
// DON'T: the script has already loaded, the cookies are already set
useEffect(() => {
if (pathname === "/") window.$crisp.push(["do", "chat:hide"]);
}, [pathname]);
chat:hide is a visibility command. The script downloaded, executed, opened a
websocket, wrote its cookies and mounted an iframe: you just made the launcher
invisible. On the landing page, the page where load performance matters most,
you have paid the entire cost of the widget and taken none of the benefit.
The fix: decide before you render the script
"use client";
import { usePathname } from "next/navigation";
import Script from "next/script";
const HIDDEN_ROUTES = ["/", "/sign-in", "/sign-up", "/pricing"];
function isHidden(pathname: string): boolean {
return HIDDEN_ROUTES.some((route) =>
route === "/" ? pathname === "/" : pathname === route || pathname.startsWith(`${route}/`),
);
}
export function CrispWidget() {
const pathname = usePathname();
const hidden = isHidden(pathname ?? "/");
// Once loaded, Crisp cannot be unloaded: there is no teardown API. So a
// navigation onto a hidden route can only hide the launcher; the guard below
// is what stops it ever loading on a landing page in the first place.
useEffect(() => {
if (hidden) hideLauncher();
else showLauncher();
}, [hidden]);
if (hidden) return null;
return <Script id="crisp-widget" src="https://client.crisp.chat/l.js" strategy="lazyOnload" />;
}
Note the asymmetry, because it catches people out. A visitor who lands directly
on / never loads Crisp at all: the ideal outcome. A visitor who is deep in
the app and then navigates to / has already loaded it, and all you can do is
hide the launcher. There is no third option: no chat widget of this kind can be
removed from a page once its script has run.
If that asymmetry matters to you (a marketing site where the landing page is the whole product) put the marketing pages in a route group whose layout does not render the widget at all, so the component is never mounted on them.
"/" needs an exact match
The most common bug in this code is a prefix check against "/", which matches
every route in the application and disables support everywhere. The helper above
special-cases it. Whatever shape yours takes, test /dashboard against it
before you ship.
The better answer for marketing pages: your own trigger
Rather than a floating bubble, put a support entry point in the page where it belongs, styled like the rest of the page:
<section className="rounded-lg border border-hairline bg-surface-card p-6">
<h2 className="text-title-md text-ink">Not sure which plan fits?</h2>
<p className="mt-2 text-body-sm text-body">
Tell us how your team works and we will point you at the right one.
</p>
<SupportButton className="mt-4" variant="solid" segments={["pre-sales"]}>
Ask a question
</SupportButton>
</section>
This is better on every axis. It is designed rather than bolted on, it appears where the doubt actually is (next to the plan comparison, not in a corner), the conversation arrives pre-segmented as pre-sales, and visitors who do not click pay nothing at all in bytes or main-thread time.
If the route is in HIDDEN_ROUTES, the script is not loaded there and your
button would open nothing. Two honest options: remove the route from the list
and keep the launcher hidden with hideLauncher() so only your button opens
chat, or link to a contact page instead. A dead button is worse than a link.
Mobile deserves its own decision
The bubble occupies proportionally more of a phone screen and lands squarely in
the thumb zone, where your primary call to action also lives. Either hide it on
small viewports and rely on an in-page trigger, or use the widget's own
hide:on:mobile configuration. Check the sticky footer CTA and the cookie
banner in the same pass: three floating elements competing for one corner is a
recurring, entirely avoidable design failure.
Verifying
- Load
/in a fresh incognito window. Network tab: no request toclient.crisp.chat. That is the whole point. - Navigate to
/dashboard. The launcher appears a moment after the page settles. - Navigate back to
/. The launcher disappears; the script stays loaded, which is expected and unavoidable. - On a phone-sized viewport, confirm the bubble does not cover the primary button on any page where it does load.
- Watch conversations by entry page for a fortnight. If pricing-page conversations convert, put the trigger back, deliberately, in the page, where you can measure it.