Live Shopping Overlay
A React live-selling UI overlay — on a fixed-ratio stage it layers a LIVE badge with a rolling viewer count, heart bursts, a scrolling comment stream, automatic "+1" order detection with floating counters, and a pinned product card with a stock countdown; drop in children to use a real video.
This is a WebberUI Pro component
The install command below needs a licence to run; without one it returns 401.
Taiwan's live-selling culture, on social and e-commerce streams alike, runs on "+1": the host presents a product and viewers type "+1" in the comments to place an order. This component packages the whole live-shop UI overlay into a fixed-ratio stage (portrait 9:16 or landscape 16:9). The base layer is a gradient with slowly drifting glows standing in for the video (pass children to use a real video), and layered on top are: a LIVE badge with a digit-by-digit rolling viewer count in the top-left, a heart button with floating particles on the right, a comment stream in the bottom-left (new messages slide in from the bottom, older ones drift up and fade, accounts get a colour-block initial as their avatar), automatic +1 detection (matching comments are highlighted, a "+1" floats up above the product card, and the order count and stock update live), and a pinned product card with a "+1 order placed." (order) button. The message list works controlled or uncontrolled, and simulate lets the component generate its own comments and viewer fluctuations; every account, comment and product is fictional demo data.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add "https://webberui.com/r/live-shopping-overlay.en.json?t=<install token>"Live Shopping Overlay is a React live shopping (live commerce) UI overlay built with Tailwind CSS and Motion, with icons from lucide-react. It handles the visuals and +1 detection only; the comment source, order creation and payment all stay in your backend.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<LiveShoppingOverlay/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- Live-selling pages on e-commerce platforms or brand sites, layered over the stream player
- A host-facing preview for group-buy or "+1" live sales, so the host can check the layout before going live
- Pitch or demo pages for live commerce products, using
simulateto show the experience in action - Prototypes of short-video or live-stream apps, testing a portrait 9:16 layout on the web
Installation
npx webberui@latest add "https://webberui.com/r/live-shopping-overlay.en.json?t=<install token>"Or, once registries are configured in components.json, install it as @webberui/live-shopping-overlay.
Usage
import {
LiveShoppingOverlay,
isPlusOne,
} from "@/components/ui/live-shopping-overlay";
// Demo mode: the comment stream and viewer count are simulated inside the component (fictional product and accounts)
<LiveShoppingOverlay
simulate
product={{ name: "Ceramic Pour-Over Filter Set", price: 680, stock: 12 }}
onPlusOne={(msg) => console.log("order", msg.user, msg.text)}
onHeart={(count) => console.log("hearts", count)}
/>
// Wired to a backend: controlled messages, viewer count from your API, a real video under the overlay
<LiveShoppingOverlay
layout="landscape"
messages={messages}
onMessagesChange={setMessages}
viewers={viewers}
product={product}
onPlusOne={(msg) => createOrder(msg)}
>
<video src={streamUrl} autoPlay muted playsInline />
</LiveShoppingOverlay>
// The order-detection helper can be used on its own (e.g. to filter comments on the server or at the form layer)
isPlusOne("+1 Black"); // true
isPlusOne("I want 1."); // true
isPlusOne("Have a promo code?"); // falseExamples
Live comments over WebSocket
In controlled mode you push new comments into messages. When the viewer presses "+1", the component calls onMessagesChange with the full array including the new comment, which is the last item.
"use client";
import * as React from "react";
import {
LiveShoppingOverlay,
type LiveMessage,
} from "@/components/ui/live-shopping-overlay";
export function LiveRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = React.useState<LiveMessage[]>([]);
React.useEffect(() => {
const ws = new WebSocket(`wss://example.com/rooms/${roomId}`);
ws.onmessage = (event) => {
const msg: LiveMessage = JSON.parse(event.data);
setMessages((prev) => [...prev, msg].slice(-50));
};
return () => ws.close();
}, [roomId]);
return (
<LiveShoppingOverlay
messages={messages}
onMessagesChange={(next) => {
const mine = next[next.length - 1];
setMessages(next);
void fetch(`/api/rooms/${roomId}/comments`, {
method: "POST",
body: JSON.stringify(mine),
});
}}
product={{ name: "Insulated tumbler 500ml", price: 890, stock: 30 }}
onPlusOne={(msg) => console.log("New order", msg.user)}
/>
);
}Each id must be unique; the component uses it to detect new comments, and the same id is never counted twice.
Landscape stage with a real video
layout="landscape" switches to 16:9, and children is stretched to fill the stage beneath every overlay.
<LiveShoppingOverlay
layout="landscape"
title="Friday night skincare live"
selfName="Ann"
showViewers={false}
maxMessages={4}
messages={messages}
onMessagesChange={setMessages}
product={product}
>
<video src={streamUrl} autoPlay muted playsInline />
</LiveShoppingOverlay>Count orders with isPlusOne
The named export isPlusOne uses the same rule as the component, so other client components can count orders with it.
import { isPlusOne } from "@/components/ui/live-shopping-overlay";
const plusOneCount = messages.filter((m) => isPlusOne(m.text)).length;Props
| Prop | Type | Default | Description |
|---|---|---|---|
messages | LiveMessage[] | — | Controlled message list (oldest to newest); when provided, internal state and simulate are disabled, and new messages should be written back through onMessagesChange |
defaultMessages | LiveMessage[] | [] | Initial messages in uncontrolled mode |
onMessagesChange | (messages: LiveMessage[]) => void | — | Callback whenever the message list changes (the "+1 order placed." button or a simulated message); in controlled mode, update messages from here |
simulate | boolean | false | In uncontrolled mode, let the component simulate a comment stream (including random +1s) on a timer; for demos only — turn it off once wired to a backend |
product | LiveProduct | built-in fictional product (NT$680, stock 20) | Pinned product (name, price, starting stock) |
viewers | number | — | Controlled viewer count; when omitted the component simulates small fluctuations every few seconds |
defaultViewers | number | 1280 | Starting value for the simulated viewer count (only used when viewers is omitted) |
showViewers | boolean | true | Whether to show the viewer count in the top-right corner |
title | string | "Sale starts Wednesday night" (Wednesday-night sale) | Stream title shown to the right of the LIVE badge |
selfName | string | "Me" (me) | Account name used for the message sent by the "+1 order placed." button |
onPlusOne | (message: LiveMessage) => void | — | Fires once for every newly detected +1 message (including your own button presses) |
onHeart | (count: number) => void | — | Fires on every heart tap with the running total |
layout | "portrait" | "landscape" | "portrait" | Stage ratio: portrait 9:16 (mobile streams) or landscape 16:9 |
maxMessages | number | 5 | Maximum number of messages shown at once; older ones drift up and fade out |
children | React.ReactNode | — | Node placed on the base layer of the stage (e.g. a real video); it is stretched to fill and sits under every overlay |
className | string | — | Forwarded to the outermost stage container |
LiveMessage
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Unique identifier; the component uses it to tell "new" messages apart, and the same id never triggers +1 twice |
user | string | — | Account name (the avatar takes its first character and a colour hashed from the name) |
text | string | — | Message body; when it matches the +1 rule it is highlighted and counted as an order |
ts | number | — | Sent time (millisecond timestamp), optional and only for the caller's sorting or display |
LiveProduct
| Field | Type | Default | Description |
|---|---|---|---|
name | string | — | Product name |
price | number | — | Price (New Taiwan dollars) |
stock | number | — | Starting stock; every +1 takes one unit, and at 0 the card shows sold out and disables the order button |
isPlusOne(text) and the LiveShoppingLayout type are also named exports, so the same order-detection rule can be reused on the server or at the form layer.
How it works
- +1 rule (
isPlusOne): after trimming, the message either matches/(^|\s)\+?1(\s|$)/(a standalone "1" or "+1", e.g. "1" or "I want 1." — I want 1) or contains "+1" / full-width "+1" anywhere (e.g. "+1Black" — +1 black, or "Reserve one for me+1" — save me one +1) - Order count and stock: every new +1 message takes one unit of stock and fires
onPlusOne; the component tracks processed messages byid, so the sameidis never counted twice. +1s in the initial messages (defaultMessages, or the firstmessagesyou pass) are folded into the starting order count but do not fire the callback or the floating animation, keeping SSR and client output identical - Controlled vs. uncontrolled: passing
messagesmakes it controlled — the component stops adding messages on its own (simulateis ignored) and the "+1 order placed." button writes back throughonMessagesChange([...messages, newMessage]), leaving it to you whether to send it to a backend. In uncontrolled mode the component keeps a few more messages thanmaxMessages, so raising the limit later still has older messages to fill in - Simulated stream: scheduled with a
setTimeoutchain rather than a fixedsetInterval, with a deterministic jitter on every gap so it reads like real people rather than a metronome; roughly a 35% chance of an order per message, chat only once sold out; no messages are added while the tab is in the background so a burst of animations does not play all at once when you return; timers are cleared on unmount - Viewer count: controlled through
viewers, or simulated fromdefaultViewerswith a small (about ±3%) drift every 3 seconds, rolling digit by digit; thousands grouping is implemented by hand instead oftoLocaleString, avoiding server/browser ICU differences that would cause hydration mismatches - The heart and "+1" particles are hand-rolled with motion; each particle's drift, sway, scale and tone come from a deterministic pseudo-random derived from its serial number, so
Math.randomis never called during render; particles are removed when their lifetime ends and their timers are cleared - The base "video" placeholder is a gradient with two slowly drifting glows, no external video or image;
childrenis stretched to fill (object-cover) and sits under every overlay, and top/bottom darkening masks keep white text, comments and the product card readable over any footage - Stock countdown: the progress bar on the product card shortens with every +1, turns red at 3 units or fewer with a "Last N Items" (last N left) label, and at 0 shows "Sold out." (sold out) and disables the order button
Accessibility
- The outermost element is
role="region"with anaria-labelcarrying the stream title; the heart button and "+1 order placed." are bothtype="button"withfocus-visiblerings, and the order button isdisabledonce sold out - The comment stream is
role="log"witharia-live="off"— a simulated message every second or two would drown a screen reader if each were announced; only when you press "+1 order placed." yourself does a hiddenrole="status"region announce the running order count and remaining stock - The viewer count is not
aria-live(it changes every few seconds); insteadsr-onlytext adds the "people watching." (viewers watching) unit; the heart button'saria-labelcarries the number of hearts sent - Every decorative element (glows, darkening masks, avatar blocks, particles, progress bar) is
aria-hidden, while the comment text itself stays readable DOM content - When the user has "reduce motion" enabled at the system level, the drifting glows, the LIVE pulse dot, the heart and "+1" particles, the rolling digits and the slide-in of comments are all disabled, leaving only the number, highlight and text updates; the intervals of the simulated stream and viewer drift are doubled
FAQ
Does the component create orders or take payments?
No. When it detects a new +1 comment it only calls onPlusOne; creating the order, checking stock and taking payment all happen in your backend.
Does the stock shown stay in sync with my backend?
Not automatically. The displayed stock is product.stock minus the +1 comments counted in the message list, so it is an on-screen running count; treat your backend's response as the source of truth for whether an order goes through.
What counts as a +1 comment?
After trimming, a comment counts if it contains a standalone "1" or "+1" (such as "1" or "I want 1"), or contains "+1" or the full-width "+1" anywhere (such as "+1 black"). The same rule is available on its own as isPlusOne.
Does it work with the Next.js App Router?
Yes. The file already starts with "use client", so import it from a Server Component page as usual. +1 comments in the initial messages are counted straight into the starting order count, and the viewer count uses its own thousands formatting, so server and browser output match.
Can I use simulate in production?
It is not recommended. simulate uses timers to generate fictional comments and viewer changes, which suits demos and prototypes; when connected to a backend, use controlled messages and viewers instead.
Free Shipping Meter
A multi-tier threshold meter (free shipping → gift → discount) for React: rolling 'NT$X more for free shipping' copy, a colour change across the whole bar and a one-shot micro-confetti burst when a tier is crossed, and reached tiers listed as chips — drop it into a cart header on its own.
Group Buy Progress
A head-count group-buy card for React: stacked participant avatars, tiered group prices, a deadline countdown, a CTA that flips to "group formed" with a card pulse and confetti the moment the threshold is hit, and an automatic refund note if the deadline passes short of the minimum.