Commerce & Checkout

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.

How to install Pro components →See the plans →

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>"

New project or installation trouble? Start here →

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.

5
Active edited usageDefault props
<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 simulate to 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?"); // false

Examples

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

PropTypeDefaultDescription
messagesLiveMessage[]—Controlled message list (oldest to newest); when provided, internal state and simulate are disabled, and new messages should be written back through onMessagesChange
defaultMessagesLiveMessage[][]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
simulatebooleanfalseIn 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
productLiveProductbuilt-in fictional product (NT$680, stock 20)Pinned product (name, price, starting stock)
viewersnumber—Controlled viewer count; when omitted the component simulates small fluctuations every few seconds
defaultViewersnumber1280Starting value for the simulated viewer count (only used when viewers is omitted)
showViewersbooleantrueWhether to show the viewer count in the top-right corner
titlestring"Sale starts Wednesday night" (Wednesday-night sale)Stream title shown to the right of the LIVE badge
selfNamestring"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
maxMessagesnumber5Maximum number of messages shown at once; older ones drift up and fade out
childrenReact.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
classNamestring—Forwarded to the outermost stage container

LiveMessage

FieldTypeDefaultDescription
idstring—Unique identifier; the component uses it to tell "new" messages apart, and the same id never triggers +1 twice
userstring—Account name (the avatar takes its first character and a colour hashed from the name)
textstring—Message body; when it matches the +1 rule it is highlighted and counted as an order
tsnumber—Sent time (millisecond timestamp), optional and only for the caller's sorting or display

LiveProduct

FieldTypeDefaultDescription
namestring—Product name
pricenumber—Price (New Taiwan dollars)
stocknumber—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 by id, so the same id is never counted twice. +1s in the initial messages (defaultMessages, or the first messages you 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 messages makes it controlled — the component stops adding messages on its own (simulate is ignored) and the "+1 order placed." button writes back through onMessagesChange([...messages, newMessage]), leaving it to you whether to send it to a backend. In uncontrolled mode the component keeps a few more messages than maxMessages, so raising the limit later still has older messages to fill in
  • Simulated stream: scheduled with a setTimeout chain rather than a fixed setInterval, 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 from defaultViewers with a small (about ±3%) drift every 3 seconds, rolling digit by digit; thousands grouping is implemented by hand instead of toLocaleString, 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.random is 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; children is 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 an aria-label carrying the stream title; the heart button and "+1 order placed." are both type="button" with focus-visible rings, and the order button is disabled once sold out
  • The comment stream is role="log" with aria-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 hidden role="status" region announce the running order count and remaining stock
  • The viewer count is not aria-live (it changes every few seconds); instead sr-only text adds the "people watching." (viewers watching) unit; the heart button's aria-label carries 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.