shadimage
wa-ui
componentcarousel-templatesource: ok · hit

Carousel Template

WhatsApp Business carousel template with horizontal scrollable cards using @base-ui/react ScrollArea.

npx shadcn add "https://ui.meta-cloud-api.site/r/carousel-template.json"
free

Interaction

carousel

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
ui-primitives@base-ui/react

Files3 · source cached 10/8/2026, 10:23:17 PM

components/ui/whatsapp/carousel-template.tsx
"use client";

import * as React from "react";
import { Button } from "@base-ui/react/button";
import { ScrollArea } from "@base-ui/react/scroll-area";
import { cn } from "@/lib/utils";
import "@/components/ui/whatsapp/styles/whatsapp.css";
import { TemplateButton } from "./template-bubble";

export interface CarouselCard {
  imageUrl?: string;
  body: string;
  buttons?: TemplateButton[];
}

export interface CarouselTemplateProps extends React.HTMLAttributes<HTMLDivElement> {
  body?: string;
  cards: CarouselCard[];
  timestamp?: string;
  /** Override the default `<a>` element for URL/phone buttons (e.g. Next.js Link) */
  renderAction?: (props: { href: string; children: React.ReactNode; className: string; target?: string; rel?: string }) => React.ReactElement;
}

function CardButton({ button, renderAction }: { button: TemplateButton; renderAction?: CarouselTemplateProps["renderAction"] }) {
  const base = "flex w-full items-center justify-center gap-1.5 py-[9px] text-[13px] font-medium text-wa-emerald-500";
  const label = button.type === "call_permission" ? `Call ${button.bizName}` : button.type === "copy_code" ? (button.label ?? "Copy offer code") : "label" in button ? button.label : "";

  const buttonContent = (
    <>
      {button.type === "url" && (
        <img src="/wa-icon-url.png" width={14} height={14} alt="" style={{ filter: "var(--wa-btn-icon-filter)" }} />
      )}
      {button.type === "flow" && (
        <img src="/wa-icon-flow.png" width={14} height={14} alt="" style={{ filter: "var(--wa-btn-icon-filter)" }} />
      )}
      {label}
    </>
  );

  const href = button.type === "url" && button.url ? button.url
    : button.type === "phone_number" && button.phone_number ? `tel:${button.phone_number}`
    : undefined;

  if (renderAction && href) {
    return renderAction({
      href,
      children: buttonContent,
      className: base,
      ...(button.type === "url" ? { target: "_blank", rel: "noopener noreferrer" } : {}),
    });
  }

  const renderEl = button.type === "url" && button.url
    ? <a href={button.url} target="_blank" rel="noopener noreferrer" />
    : button.type === "phone_number" && button.phone_number
    ? <a href={`tel:${button.phone_number}`} />
    : undefined;

  return (
    <Button render={renderEl} nativeButton={renderEl == null} className={base}>
      {buttonContent}
    </Button>
  );
}

const CarouselTemplate = React.forwardRef<HTMLDivElement, CarouselTemplateProps>(
  ({ className, body, cards, timestamp, renderAction, ...props }, ref) => {
    return (
      <div ref={ref} className={cn("font-wa w-full", className)} {...props}>
        {/* Optional intro bubble */}
        {body && (
          <div className="mb-1 max-w-[280px] rounded-lg bg-wa-bubble-incoming px-[9px] py-[6px] shadow-sm">
            <p className="text-[14.2px] leading-[19px] text-wa-text-primary">{body}</p>
            {timestamp && (
              <div className="mt-1 flex justify-end">
                <span className="text-[11px] text-wa-bubble-meta">{timestamp}</span>
              </div>
            )}
          </div>
        )}

        {/* Cards row */}
        <ScrollArea.Root className="pb-1">
          <ScrollArea.Viewport className="flex gap-2" style={{ scrollSnapType: "x mandatory" }}>
          <ScrollArea.Content className="flex gap-2">
          {cards.map((card, i) => (
            <div
              key={i}
              className="w-[220px] shrink-0 overflow-hidden rounded-lg bg-wa-bubble-incoming shadow-sm"
              style={{ scrollSnapAlign: "start" }}
            >
              {/* Card image */}
              <div className="h-[130px] w-full bg-wa-gray-200">
                {card.imageUrl ? (
                  <img src={card.imageUrl} alt="" className="h-full w-full object-cover" />
                ) : (
                  <div className="flex h-full items-center justify-center">
                    <svg viewBox="0 0 24 24" width="36" height="36" className="text-wa-gray-400">
                      <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" fill="currentColor" />
                    </svg>
                  </div>
                )}
              </div>

              {/* Card body */}
              <div className="px-[9px] py-[6px]">
                <p className="text-[13px] leading-[18px] text-wa-text-primary">{card.body}</p>
              </div>

              {/* Card buttons */}
              {card.buttons && card.buttons.length > 0 && (
                <div className="border-t border-wa-border">
                  {card.buttons.map((btn, j) => (
                    <div key={j} className={cn(j > 0 && "border-t border-wa-border")}>
                      <CardButton button={btn} renderAction={renderAction} />
                    </div>
                  ))}
                </div>
              )}
            </div>
          ))}
          </ScrollArea.Content>
          </ScrollArea.Viewport>
          <ScrollArea.Scrollbar orientation="horizontal" className="flex h-1.5 flex-col px-[3px] py-[3px]">
            <ScrollArea.Thumb className="flex-1 rounded-full bg-wa-gray-300" />
          </ScrollArea.Scrollbar>
        </ScrollArea.Root>
      </div>
    );
  }
);
CarouselTemplate.displayName = "CarouselTemplate";

export { CarouselTemplate };
components/ui/whatsapp/template-bubble.tsx
"use client";

import * as React from "react";
import { Button } from "@base-ui/react/button";
import { cn } from "@/lib/utils";
import "@/components/ui/whatsapp/styles/whatsapp.css";

// ─── Types ────────────────────────────────────────────────────────────────────

type HeaderType = "text" | "image" | "video" | "document" | "location";

interface LocationHeader {
  name: string;
  address: string;
  imageUrl?: string;
}

interface TemplateHeader {
  type: HeaderType;
  text?: string;
  imageUrl?: string;
  videoUrl?: string;
  documentName?: string;
  location?: LocationHeader;
}

export type TemplateButton =
  // CTA — opens URL in browser
  | { type: "url";          label: string; url?: string }
  // CTA — initiates phone call
  | { type: "phone_number"; label: string; phone_number?: string }
  // User reply with pre-set text
  | { type: "quick_reply";  label: string }
  // Copies a coupon/promo code to clipboard
  | { type: "copy_code";    label?: string; code?: string }
  // Opens a WhatsApp Flow
  | { type: "flow";         label: string }
  // Opens a WhatsApp Catalog
  | { type: "catalog";      label?: string }
  // One-tap autofill OTP (authentication templates)
  | { type: "otp";          label?: string }
  // Calling permission request (custom display type)
  | { type: "call_permission"; bizName: string };

export interface TemplateBubbleProps
  extends React.HTMLAttributes<HTMLDivElement> {
  variant?: "incoming" | "outgoing";
  header?: TemplateHeader;
  body: string;
  footer?: string;
  buttons?: TemplateButton[];
  timestamp?: string;
  /** Override the default `<a>` element for URL/phone buttons (e.g. Next.js Link) */
  renderAction?: (props: { href: string; children: React.ReactNode; className: string; target?: string; rel?: string }) => React.ReactElement;
}

// ─── Variable highlighting ─────────────────────────────────────────────────

function BodyText({ text }: { text: string }) {
  const parts = text.split(/(\{\{[^}]+\}\}|\{[^}]+\})/g);
  return (
    <>
      {parts.map((part, i) =>
        /^\{/.test(part) ? (
          <span key={i} className="text-wa-emerald-600">
            {part}
          </span>
        ) : (
          <React.Fragment key={i}>{part}</React.Fragment>
        )
      )}
    </>
  );
}

// ─── Header variants ──────────────────────────────────────────────────────────

function ImageHeader({ imageUrl }: { imageUrl?: string }) {
  return (
    <div className="h-[180px] w-full overflow-hidden rounded-t-lg" style={{ backgroundColor: "var(--wa-placeholder-bg)" }}>
      {imageUrl ? (
        <img src={imageUrl} alt="" className="h-full w-full object-cover" />
      ) : (
        <div className="flex h-full items-center justify-center">
          <img src="/wa-header-image.png" width={60} height={60} alt="" style={{ filter: "var(--wa-placeholder-icon)" }} />
        </div>
      )}
    </div>
  );
}

function VideoHeader({ videoUrl }: { videoUrl?: string }) {
  return (
    <div className="h-[180px] w-full overflow-hidden rounded-t-lg" style={{ backgroundColor: "var(--wa-placeholder-bg)" }}>
      {videoUrl ? (
        <video src={videoUrl} className="h-full w-full object-cover" />
      ) : (
        <div className="flex h-full items-center justify-center">
          <img src="/wa-header-video.png" width={60} height={60} alt="" />
        </div>
      )}
    </div>
  );
}

function DocumentHeader({ documentName }: { documentName?: string }) {
  return (
    <div className="flex h-[130px] w-full flex-col items-center justify-center gap-2 rounded-t-lg" style={{ backgroundColor: "var(--wa-placeholder-bg)" }}>
      <img src="/wa-header-document.png" width={52} height={52} alt="" style={{ filter: "var(--wa-placeholder-icon)" }} />
      {documentName && (
        <span className="text-[12px]" style={{ color: "var(--wa-icon-lighter)" }}>{documentName}</span>
      )}
    </div>
  );
}

function LocationHeader({ location }: { location?: LocationHeader }) {
  return (
    <div className="relative h-[160px] w-full overflow-hidden rounded-t-lg" style={{ backgroundColor: "var(--wa-placeholder-bg)" }}>
      {location?.imageUrl ? (
        <img src={location.imageUrl} alt="" className="h-full w-full object-cover" />
      ) : (
        <div className="flex h-full items-center justify-center">
          <img src="/wa-header-location.png" width={60} height={60} alt="" style={{ filter: "var(--wa-placeholder-icon)" }} />
        </div>
      )}
      {(location?.name || location?.address) && (
        <div className="absolute bottom-0 left-0 right-0 bg-black/30 px-3 py-2">
          {location.name && (
            <p className="text-[13px] font-medium text-white"><BodyText text={location.name} /></p>
          )}
          {location.address && (
            <p className="text-[12px] text-white/80"><BodyText text={location.address} /></p>
          )}
        </div>
      )}
    </div>
  );
}

// ─── Icons ────────────────────────────────────────────────────────────────────

const ICONS: Partial<Record<TemplateButton["type"], React.ReactNode>> = {
  url:          <img src="/wa-icon-url.png"   width={16} height={16} alt="" style={{ filter: "var(--wa-btn-icon-filter)" }} />,
  phone_number: <img src="/wa-icon-phone.png" width={16} height={16} alt="" style={{ filter: "var(--wa-btn-icon-filter)" }} />,
  quick_reply:  <img src="/wa-icon-phone.png" width={16} height={16} alt="" style={{ filter: "var(--wa-btn-icon-filter)" }} />,
  flow:         <img src="/wa-icon-flow.png"  width={16} height={16} alt="" style={{ filter: "var(--wa-btn-icon-filter)" }} />,
  catalog:      <img src="/wa-icon-url.png"   width={16} height={16} alt="" style={{ filter: "var(--wa-btn-icon-filter)" }} />,
  // copy_code / otp — inline SVG clipboard icon
  copy_code: (
    <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" style={{ filter: "var(--wa-btn-icon-filter)" }}>
      <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
    </svg>
  ),
  otp: (
    <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" style={{ filter: "var(--wa-btn-icon-filter)" }}>
      <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
    </svg>
  ),
};

// ─── Buttons ──────────────────────────────────────────────────────────────────

function TemplateButtonRow({ button, renderAction }: { button: TemplateButton; renderAction?: TemplateBubbleProps["renderAction"] }) {
  const [copied, setCopied] = React.useState(false);
  const base = "flex w-full items-center justify-center gap-1.5 py-[10px] text-[14px] font-medium text-wa-emerald-500";

  if (button.type === "call_permission") {
    return (
      <Button className={cn(base, "flex-col gap-0.5 py-3 text-wa-text-primary")}>
        <div className="flex items-center gap-2">
          <div className="flex h-9 w-9 items-center justify-center rounded-full bg-wa-emerald-500 text-white">
            <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
              <path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" />
            </svg>
          </div>
          <span className="text-[14px] font-medium">
            Can <span className="font-semibold">{button.bizName}</span> call you?
          </span>
        </div>
      </Button>
    );
  }

  const label =
    button.type === "copy_code" ? (button.label ?? "Copy offer code") :
    button.type === "otp"       ? (button.label ?? "Copy code") :
    button.type === "catalog"   ? (button.label ?? "View catalog") :
    "label" in button           ? button.label : "";

  const isCopyType = button.type === "copy_code" || button.type === "otp";
  const copyCode = isCopyType && "code" in button ? button.code : undefined;

  const handleClick = copyCode
    ? () => {
        navigator.clipboard.writeText(copyCode);
        setCopied(true);
        setTimeout(() => setCopied(false), 2000);
      }
    : undefined;

  const buttonContent = (
    <>
      {copied ? (
        <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" style={{ filter: "var(--wa-btn-icon-filter)" }}>
          <path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/>
        </svg>
      ) : (
        ICONS[button.type] ?? null
      )}
      {copied ? "Copied!" : label}
    </>
  );

  // Use renderAction if provided for link-type buttons
  const href = button.type === "url" && button.url ? button.url
    : button.type === "phone_number" && button.phone_number ? `tel:${button.phone_number}`
    : undefined;

  if (renderAction && href) {
    return renderAction({
      href,
      children: buttonContent,
      className: base,
      ...(button.type === "url" ? { target: "_blank", rel: "noopener noreferrer" } : {}),
    });
  }

  const renderEl = (() => {
    if (button.type === "url" && button.url)
      return <a href={button.url} target="_blank" rel="noopener noreferrer" />;
    if (button.type === "phone_number" && button.phone_number)
      return <a href={`tel:${button.phone_number}`} />;
    return undefined;
  })();

  return (
    <Button
      render={renderEl}
      nativeButton={renderEl == null}
      className={base}
      onClick={handleClick}
    >
      {buttonContent}
    </Button>
  );
}

// ─── Main component ───────────────────────────────────────────────────────────

const TemplateBubble = React.forwardRef<HTMLDivElement, TemplateBubbleProps>(
  ({ className, variant = "incoming", header, body, footer, buttons = [], timestamp, renderAction, ...props }, ref) => {
    const hasButtons = buttons.length > 0;
    const isOutgoing = variant === "outgoing";

    return (
      <div
        ref={ref}
        className={cn("font-wa w-full min-w-[200px] max-w-[320px]", className)}
        {...props}
      >
        {/* Bubble */}
        <div className={cn("overflow-hidden rounded-lg shadow-sm", isOutgoing ? "bg-wa-bubble-outgoing" : "bg-wa-bubble-incoming")}>
          {/* Header */}
          {header?.type === "image" && <ImageHeader imageUrl={header.imageUrl} />}
          {header?.type === "video" && <VideoHeader videoUrl={header.videoUrl} />}
          {header?.type === "document" && <DocumentHeader documentName={header.documentName} />}
          {header?.type === "location" && <LocationHeader location={header.location} />}

          {/* Body */}
          <div className="px-[9px] pb-[7px] pt-[6px]">
            {header?.type === "text" && header.text && (
              <p className="mb-1 text-[14.2px] font-bold leading-[19px] text-wa-text-primary">
                <BodyText text={header.text} />
              </p>
            )}
            <p className="text-[14.2px] leading-[19px] text-wa-text-primary">
              <BodyText text={body} />
            </p>
            {footer && (
              <p className="mt-1 text-[12px] leading-[16px] text-wa-text-secondary">
                {footer}
              </p>
            )}
            {timestamp && (
              <div className="mt-[2px] flex justify-end">
                <span className="text-[11px] leading-[15px] text-wa-bubble-meta">{timestamp}</span>
              </div>
            )}
          </div>

          {/* Buttons */}
          {hasButtons && (
            <div className="border-t border-wa-border">
              {buttons.map((btn, i) => (
                <div key={i} className={cn(i > 0 && "border-t border-wa-border")}>
                  <TemplateButtonRow button={btn} renderAction={renderAction} />
                </div>
              ))}
            </div>
          )}
        </div>
      </div>
    );
  }
);
TemplateBubble.displayName = "TemplateBubble";

export { TemplateBubble };
components/ui/whatsapp/styles/whatsapp.css
/*
 * WhatsApp UI Design Tokens
 * Extracted from web.whatsapp.com WDS (WhatsApp Design System)
 */

@theme inline {
  /* === Primary Brand Colors === */
  --color-wa-green-50: var(--wa-green-50);
  --color-wa-green-75: var(--wa-green-75);
  --color-wa-green-100: var(--wa-green-100);
  --color-wa-green-200: var(--wa-green-200);
  --color-wa-green-300: var(--wa-green-300);
  --color-wa-green-400: var(--wa-green-400);
  --color-wa-green-450: var(--wa-green-450);
  --color-wa-green-500: var(--wa-green-500);
  --color-wa-green-600: var(--wa-green-600);
  --color-wa-green-700: var(--wa-green-700);
  --color-wa-green-750: var(--wa-green-750);
  --color-wa-green-800: var(--wa-green-800);

  /* === Emerald (Teal) === */
  --color-wa-emerald-400: var(--wa-emerald-400);
  --color-wa-emerald-500: var(--wa-emerald-500);
  --color-wa-emerald-600: var(--wa-emerald-600);

  /* === Neutral Gray === */
  --color-wa-gray-50: var(--wa-gray-50);
  --color-wa-gray-75: var(--wa-gray-75);
  --color-wa-gray-100: var(--wa-gray-100);
  --color-wa-gray-200: var(--wa-gray-200);
  --color-wa-gray-300: var(--wa-gray-300);
  --color-wa-gray-400: var(--wa-gray-400);
  --color-wa-gray-500: var(--wa-gray-500);
  --color-wa-gray-600: var(--wa-gray-600);
  --color-wa-gray-700: var(--wa-gray-700);
  --color-wa-gray-800: var(--wa-gray-800);
  --color-wa-gray-850: var(--wa-gray-850);
  --color-wa-gray-900: var(--wa-gray-900);
  --color-wa-gray-1000: var(--wa-gray-1000);

  /* === Sky Blue (Read Receipts) === */
  --color-wa-sky-blue-300: var(--wa-sky-blue-300);
  --color-wa-sky-blue-400: var(--wa-sky-blue-400);

  /* === Red (Destructive/Negative) === */
  --color-wa-red-300: var(--wa-red-300);
  --color-wa-red-400: var(--wa-red-400);

  /* === Cobalt === */
  --color-wa-cobalt-300: var(--wa-cobalt-300);
  --color-wa-cobalt-400: var(--wa-cobalt-400);

  /* === Persistent/Brand === */
  --color-wa-verified: var(--wa-verified);
  --color-wa-always-white: var(--wa-always-white);
  --color-wa-always-black: var(--wa-always-black);

  /* === Profile Photo Avatars === */
  --color-wa-avatar-brown: var(--wa-avatar-brown);
  --color-wa-avatar-cobalt: var(--wa-avatar-cobalt);
  --color-wa-avatar-green: var(--wa-avatar-green);
  --color-wa-avatar-orange: var(--wa-avatar-orange);
  --color-wa-avatar-pink: var(--wa-avatar-pink);
  --color-wa-avatar-purple: var(--wa-avatar-purple);
  --color-wa-avatar-red: var(--wa-avatar-red);
  --color-wa-avatar-sky-blue: var(--wa-avatar-sky-blue);
  --color-wa-avatar-teal: var(--wa-avatar-teal);
  --color-wa-avatar-yellow: var(--wa-avatar-yellow);

  /* === Semantic Colors (theme-aware) === */
  --color-wa-bg: var(--wa-bg);
  --color-wa-bg-deeper: var(--wa-bg-deeper);
  --color-wa-bg-wash: var(--wa-bg-wash);
  --color-wa-panel-bg: var(--wa-panel-bg);
  --color-wa-panel-header-bg: var(--wa-panel-header-bg);
  --color-wa-conversation-bg: var(--wa-conversation-bg);
  --color-wa-compose-bg: var(--wa-compose-bg);

  --color-wa-text-primary: var(--wa-text-primary);
  --color-wa-text-secondary: var(--wa-text-secondary);
  --color-wa-text-deemphasized: var(--wa-text-deemphasized);

  --color-wa-bubble-outgoing: var(--wa-bubble-outgoing);
  --color-wa-bubble-incoming: var(--wa-bubble-incoming);
  --color-wa-bubble-system: var(--wa-bubble-system);
  --color-wa-bubble-meta: var(--wa-bubble-meta);

  --color-wa-border: var(--wa-border);
  --color-wa-divider: var(--wa-divider);

  --color-wa-primary: var(--wa-primary);
  --color-wa-primary-hover: var(--wa-primary-hover);
  --color-wa-icon-default: var(--wa-icon-default);
  --color-wa-icon-lighter: var(--wa-icon-lighter);
  --color-wa-hover: var(--wa-hover);
  --color-wa-active: var(--wa-active);

  --color-wa-read: var(--wa-read);
  --color-wa-delivered: var(--wa-delivered);

  --color-wa-unread-marker: var(--wa-unread-marker);
  --color-wa-typing-dot: var(--wa-typing-dot);
  --color-wa-input-bg: var(--wa-input-bg);
  --color-wa-input-border: var(--wa-input-border);

  --color-wa-wallpaper-bg: var(--wa-wallpaper-bg);
  --color-wa-wallpaper-fg: var(--wa-wallpaper-fg);

  /* === Additional Color Scales === */
  --color-wa-teal-300: var(--wa-teal-300);
  --color-wa-teal-400: var(--wa-teal-400);
  --color-wa-teal-500: var(--wa-teal-500);

  --color-wa-purple-300: var(--wa-purple-300);
  --color-wa-purple-400: var(--wa-purple-400);

  --color-wa-orange-300: var(--wa-orange-300);
  --color-wa-orange-400: var(--wa-orange-400);

  --color-wa-yellow-300: var(--wa-yellow-300);
  --color-wa-yellow-400: var(--wa-yellow-400);

  --color-wa-pink-300: var(--wa-pink-300);
  --color-wa-pink-400: var(--wa-pink-400);

  /* === Attachment Type Colors === */
  --color-wa-attachment-document: var(--wa-attachment-document);
  --color-wa-attachment-audio: var(--wa-attachment-audio);
  --color-wa-attachment-photo: var(--wa-attachment-photo);
  --color-wa-attachment-contact: var(--wa-attachment-contact);
  --color-wa-attachment-camera: var(--wa-attachment-camera);
  --color-wa-attachment-sticker: var(--wa-attachment-sticker);
  --color-wa-attachment-poll: var(--wa-attachment-poll);
  --color-wa-attachment-catalog: var(--wa-attachment-catalog);

  /* === Semantic Text === */
  --color-wa-text-disabled: var(--wa-text-disabled);
  --color-wa-text-critical: var(--wa-text-critical);
  --color-wa-text-success: var(--wa-text-success);
  --color-wa-text-action: var(--wa-text-action);
  --color-wa-system-msg: var(--wa-system-msg);
  --color-wa-primary-title: var(--wa-primary-title);

  /* === Typography === */
  --font-wa: "Segoe UI", "Helvetica Neue", Helvetica, "Lucida Grande", Arial,
    Ubuntu, Cantarell, "Fira Sans", sans-serif;

  /* === Layout Constants === */
  --spacing-wa-sidebar-width: var(--wa-sidebar-width);
  --spacing-wa-sidebar-min-width: var(--wa-sidebar-min-width);
  --spacing-wa-header-height: var(--wa-header-height);
  --spacing-wa-compose-height: var(--wa-compose-height);
  --spacing-wa-chat-spacing: var(--wa-chat-spacing);
  --spacing-wa-avatar-sm: var(--wa-avatar-size-sm);
  --spacing-wa-avatar-md: var(--wa-avatar-size-md);
  --spacing-wa-avatar-lg: var(--wa-avatar-size-lg);

  /* === Border Radius === */
  --radius-wa-bubble: var(--wa-bubble-radius);

  /* === Animation === */
  --animate-wa-typing-bounce: wa-typing-bounce 1.2s ease-in-out infinite;
}

@keyframes wa-typing-bounce {
  0%,
  60%,
  100% {
    transform: translateY(0);
  }
  30% {
    transform: translateY(-4px);
  }
}

:root {
  /* === Primary Brand Colors === */
  --wa-green-50: #f2fdf0;
  --wa-green-75: #e7fce3;
  --wa-green-100: #d9fdd3;
  --wa-green-200: #acfcac;
  --wa-green-300: #71eb85;
  --wa-green-400: #25d366;
  --wa-green-450: #21c063;
  --wa-green-500: #1daa61;
  --wa-green-600: #1b8755;
  --wa-green-700: #15603e;
  --wa-green-750: #144d37;
  --wa-green-800: #103529;

  /* === Emerald (Teal) === */
  --wa-emerald-400: #06cf9c;
  --wa-emerald-500: #00a884;
  --wa-emerald-600: #008069;

  /* === Neutral Gray === */
  --wa-gray-50: #fafafa;
  --wa-gray-75: #f4f4f4;
  --wa-gray-100: #eeeeee;
  --wa-gray-200: #d8d8d8;
  --wa-gray-300: #bdbdbd;
  --wa-gray-400: #959393;
  --wa-gray-500: #757778;
  --wa-gray-600: #606263;
  --wa-gray-700: #424445;
  --wa-gray-800: #242626;
  --wa-gray-850: #1d1f1f;
  --wa-gray-900: #161717;
  --wa-gray-1000: #0a0a0a;

  /* === Sky Blue (Read Receipts) === */
  --wa-sky-blue-300: #53bdeb;
  --wa-sky-blue-400: #009de2;

  /* === Red (Destructive/Negative) === */
  --wa-red-300: #fb5061;
  --wa-red-400: #f53947;

  /* === Cobalt === */
  --wa-cobalt-300: #53a6fd;
  --wa-cobalt-400: #007bfc;

  /* === Persistent/Brand === */
  --wa-verified: #0085f4;
  --wa-always-white: #ffffff;
  --wa-always-black: #000000;

  /* === Profile Photo Avatars === */
  --wa-avatar-brown: #dba685;
  --wa-avatar-cobalt: #53a6fd;
  --wa-avatar-green: #25d366;
  --wa-avatar-orange: #fc9775;
  --wa-avatar-pink: #ff72a1;
  --wa-avatar-purple: #a791ff;
  --wa-avatar-red: #fb5061;
  --wa-avatar-sky-blue: #53bdeb;
  --wa-avatar-teal: #42c7b8;
  --wa-avatar-yellow: #ffd279;
}

/* === Light Mode (WhatsApp Default) === */
:root,
[data-theme="light"] {
  /* Surfaces */
  --wa-bg: #ffffff;
  --wa-bg-deeper: #f0f2f5;
  --wa-bg-wash: #f7f8fa;
  --wa-panel-bg: #ffffff;
  --wa-panel-header-bg: #ffffff;
  --wa-conversation-bg: #f5f0e8;
  --wa-compose-bg: #f0f2f5;

  /* Content */
  --wa-text-primary: #111b21;
  --wa-text-secondary: #667781;
  --wa-text-deemphasized: rgba(17, 27, 33, 0.4);

  /* Chat Bubbles */
  --wa-bubble-outgoing: #e7ffdb;
  --wa-bubble-incoming: #ffffff;
  --wa-bubble-system: #ffecd2;
  --wa-bubble-radius: 7.5px;
  --wa-bubble-meta: rgba(17, 27, 33, 0.45);

  /* Lines */
  --wa-border: #e9edef;
  --wa-divider: rgba(134, 150, 160, 0.15);

  /* Interactive */
  --wa-primary: #008069;
  --wa-primary-hover: #017561;
  --wa-icon-default: #54656f;
  --wa-icon-lighter: #8696a0;
  --wa-hover: rgba(17, 27, 33, 0.05);
  --wa-active: rgba(17, 27, 33, 0.1);

  /* Read Receipts */
  --wa-read: #53bdeb;
  --wa-delivered: #8696a0;

  /* Misc */
  --wa-unread-marker: #25d366;
  --wa-typing-dot: #8696a0;
  --wa-input-bg: #ffffff;
  --wa-input-border: #e4e6eb;

  /* Chat Wallpaper */
  --wa-wallpaper-bg: #f5f0e8;
  --wa-wallpaper-fg: rgba(17, 27, 33, 0.05);
  --wa-wallpaper-image: url("/wa-wallpaper.png");
  --wa-wallpaper-tint: rgba(229, 221, 213, 0.85);

  /* Template header placeholder */
  --wa-placeholder-bg: #ccd0d5;
  --wa-placeholder-icon: brightness(0) saturate(100%) invert(42%) sepia(9%) saturate(621%) hue-rotate(155deg) brightness(94%) contrast(87%);

  /* Template button icon — converts PNG to wa-emerald-500 (#00a884) */
  --wa-btn-icon-filter: brightness(0) saturate(100%) invert(50%) sepia(97%) saturate(372%) hue-rotate(128deg) brightness(97%);

  /* === Additional Color Scales === */
  --wa-teal-300: #42c7b8;
  --wa-teal-400: #02a698;
  --wa-teal-500: #028377;

  --wa-purple-300: #a791ff;
  --wa-purple-400: #7f66ff;

  --wa-orange-300: #fc9775;
  --wa-orange-400: #fa6533;

  --wa-yellow-300: #ffd279;
  --wa-yellow-400: #ffb938;

  --wa-pink-300: #ff72a1;
  --wa-pink-400: #ff2e74;

  /* === Attachment Type Colors === */
  --wa-attachment-document: #7f66ff;
  --wa-attachment-audio: #fa6533;
  --wa-attachment-photo: #007bfc;
  --wa-attachment-contact: #009de2;
  --wa-attachment-camera: #ff2e74;
  --wa-attachment-sticker: #02a698;
  --wa-attachment-poll: #ffbc38;
  --wa-attachment-catalog: #3a5564;

  /* === Semantic Text === */
  --wa-text-disabled: #d1d7db;
  --wa-text-critical: #ea0038;
  --wa-text-success: #1fa855;
  --wa-text-action: #008069;
  --wa-system-msg: #54656f;
  --wa-primary-title: #41525d;

  /* === Shadows === */
  --wa-shadow-light: rgba(11, 20, 26, .08);
  --wa-shadow-medium: rgba(11, 20, 26, .15);
  --wa-border-bubble: rgba(17, 27, 33, .06);

  /* === Button States === */
  --wa-button-primary-hover: #017561;
  --wa-filter-text: #54656f;
  --wa-filter-active: #008069;
  --wa-filter-bg-active: #d9fdd3;

  /* === Spacing === */
  --wa-text-size-small: 12.6px;
  --wa-bubble-padding-start: 9px;
  --wa-bubble-padding-end: 7px;
}

/* === Dark Mode === */
[data-theme="dark"],
.dark {
  /* Surfaces */
  --wa-bg: #111b21;
  --wa-bg-deeper: #0b141a;
  --wa-bg-wash: #161717;
  --wa-panel-bg: #111b21;
  --wa-panel-header-bg: #202c33;
  --wa-conversation-bg: #0b141a;
  --wa-compose-bg: #202c33;

  /* Content */
  --wa-text-primary: #e9edef;
  --wa-text-secondary: #8696a0;
  --wa-text-deemphasized: rgba(233, 237, 239, 0.4);

  /* Chat Bubbles */
  --wa-bubble-outgoing: #005c4b;
  --wa-bubble-incoming: #202c33;
  --wa-bubble-system: #182229;
  --wa-bubble-meta: rgba(255, 255, 255, 0.6);

  /* Lines */
  --wa-border: rgba(233, 237, 239, 0.12);
  --wa-divider: rgba(134, 150, 160, 0.15);

  /* Interactive */
  --wa-primary: #00a884;
  --wa-primary-hover: #06cf9c;
  --wa-icon-default: #aebac1;
  --wa-icon-lighter: #8696a0;
  --wa-hover: rgba(233, 237, 239, 0.05);
  --wa-active: rgba(233, 237, 239, 0.1);

  /* Read Receipts */
  --wa-read: #53bdeb;
  --wa-delivered: #aebac1;

  /* Misc */
  --wa-unread-marker: #00a884;
  --wa-typing-dot: #8696a0;
  --wa-input-bg: #2a3942;
  --wa-input-border: rgba(233, 237, 239, 0.12);

  /* Chat Wallpaper */
  --wa-wallpaper-bg: #0b141a;
  --wa-wallpaper-fg: rgba(255, 255, 255, 0.06);
  --wa-wallpaper-image: url("/wa-wallpaper.png");
  --wa-wallpaper-tint: rgba(11, 20, 26, 0.88);

  /* Template header placeholder */
  --wa-placeholder-bg: #2a3942;
  --wa-placeholder-icon: brightness(0) saturate(100%) invert(62%) sepia(10%) saturate(400%) hue-rotate(155deg) brightness(95%) contrast(85%);

  /* Template button icon — converts PNG to wa-emerald-400 (#06cf9c) */
  --wa-btn-icon-filter: brightness(0) saturate(100%) invert(68%) sepia(60%) saturate(400%) hue-rotate(118deg) brightness(100%);

  /* === Semantic Text (dark) === */
  --wa-text-disabled: #3b4a54;
  --wa-text-critical: #ff6b7a;
  --wa-text-success: #25d366;
  --wa-text-action: #00a884;
  --wa-system-msg: #8696a0;
  --wa-primary-title: #8696a0;

  /* === Shadows (dark) === */
  --wa-shadow-light: rgba(0, 0, 0, .3);
  --wa-shadow-medium: rgba(0, 0, 0, .4);
  --wa-border-bubble: rgba(255, 255, 255, .06);

  /* === Button States (dark) === */
  --wa-button-primary-hover: #06cf9c;
  --wa-filter-text: #8696a0;
  --wa-filter-active: #00a884;
  --wa-filter-bg-active: #005c4b;
}

/* === Wallpaper === */
.wa-wallpaper {
  position: relative;
  background-color: var(--wa-wallpaper-bg);
  background-image: var(--wa-wallpaper-image);
  background-repeat: repeat;
  background-size: 412px auto;
  background-blend-mode: overlay;
}

.dark .wa-wallpaper,
[data-theme="dark"] .wa-wallpaper {
  background-blend-mode: soft-light;
}

/* === Layout Constants === */
:root {
  --wa-sidebar-width: 440px;
  --wa-sidebar-min-width: 340px;
  --wa-header-height: 59px;
  --wa-compose-height: 62px;
  --wa-chat-spacing: 15px;
  --wa-avatar-size-sm: 40px;
  --wa-avatar-size-md: 49px;
  --wa-avatar-size-lg: 200px;
  --wa-msg-max-width: 60%;
}

More from wa-ui

Browse all components in this library →