wa-ui
componentcta-url-bubblesource: ok · hit
CTA URL Bubble
WhatsApp interactive CTA URL message — single external link button with body/footer.
npx shadcn add "https://ui.meta-cloud-api.site/r/cta-url-bubble.json"free
Where it goes
ctafooter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightui-primitives@base-ui/react
Files3 · source cached 10/8/2026, 6:47:41 PM
components/ui/whatsapp/cta-url-bubble.tsx
"use client";
import * as React from "react";
import { Button } from "@base-ui/react/button";
import { cn } from "@/lib/utils";
import { type MessageStatus, MessageStatusIcon } from "./message-status";
import "@/components/ui/whatsapp/styles/whatsapp.css";
export interface CtaUrlBubbleProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: "incoming" | "outgoing";
body?: string;
footer?: string;
/** Button display text */
displayText: string;
/** Target URL — renders as <a> when provided */
url?: string;
timestamp?: string;
status?: MessageStatus;
showTail?: boolean;
/** Override the default `<a>` element for the CTA button (e.g. Next.js Link) */
renderAction?: (props: { href: string; children: React.ReactNode; className: string; target?: string; rel?: string }) => React.ReactElement;
}
const CtaUrlBubble = React.forwardRef<HTMLDivElement, CtaUrlBubbleProps>(
(
{
className,
variant = "incoming",
body,
footer,
displayText,
url,
timestamp,
status,
showTail = false,
renderAction,
...props
},
ref
) => {
const isOutgoing = variant === "outgoing";
return (
<div
className={cn(
"flex w-full",
isOutgoing ? "justify-end" : "justify-start",
showTail ? "mb-[6px]" : "mb-[2px]",
className
)}
{...props}
ref={ref}
>
<div
className={cn(
"font-wa relative w-[320px] overflow-hidden rounded-lg",
isOutgoing ? "bg-wa-bubble-outgoing" : "bg-wa-bubble-incoming",
showTail && (isOutgoing ? "rounded-br-none" : "rounded-bl-none")
)}
>
<div className="px-[9px] pb-[7px] pt-[6px]">
{body && <p className="text-[14.2px] leading-[19px] text-wa-text-primary">{body}</p>}
{footer && (
<p className="mt-[4px] text-[12.5px] leading-[17px] text-wa-text-secondary">{footer}</p>
)}
<div className="mt-[2px] flex items-center justify-end gap-[3px]">
{timestamp && <span className="text-[11px] leading-[15px] text-wa-bubble-meta">{timestamp}</span>}
{isOutgoing && status && <MessageStatusIcon status={status} />}
</div>
</div>
<div className="border-t border-wa-border">
{renderAction && url ? (
renderAction({
href: url,
className: "flex w-full items-center justify-center gap-1.5 py-[10px] text-[14px] font-medium text-wa-emerald-500 transition-colors hover:bg-wa-hover",
target: "_blank",
rel: "noopener noreferrer",
children: (
<>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M19 19H5V5h7V3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z" />
</svg>
{displayText}
</>
),
})
) : (
<Button
render={url ? <a href={url} target="_blank" rel="noopener noreferrer" /> : undefined}
nativeButton={!url}
className="flex w-full items-center justify-center gap-1.5 py-[10px] text-[14px] font-medium text-wa-emerald-500 transition-colors hover:bg-wa-hover"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M19 19H5V5h7V3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z" />
</svg>
{displayText}
</Button>
)}
</div>
</div>
</div>
);
}
);
CtaUrlBubble.displayName = "CtaUrlBubble";
export { CtaUrlBubble };
components/ui/whatsapp/message-status.tsx
"use client";
import * as React from "react";
import "@/components/ui/whatsapp/styles/whatsapp.css";
export type MessageStatus = "sending" | "sent" | "delivered" | "read" | "failed";
function CheckIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 11" height="11" width="16" className={className}>
<path d="M11.071.653a.457.457 0 0 0-.304-.102.493.493 0 0 0-.381.178l-6.19 7.636-2.011-2.175a.463.463 0 0 0-.349-.158.467.467 0 0 0-.338.131.537.537 0 0 0-.14.353.54.54 0 0 0 .13.363l2.39 2.576a.465.465 0 0 0 .349.158.457.457 0 0 0 .368-.189l6.596-8.14a.504.504 0 0 0 .103-.36.516.516 0 0 0-.223-.271z" fill="currentColor" />
</svg>
);
}
function DoubleCheckIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 11" height="11" width="16" className={className}>
<path d="M11.071.653a.457.457 0 0 0-.304-.102.493.493 0 0 0-.381.178l-6.19 7.636-2.011-2.175a.463.463 0 0 0-.349-.158.467.467 0 0 0-.338.131.537.537 0 0 0-.14.353.54.54 0 0 0 .13.363l2.39 2.576a.465.465 0 0 0 .349.158.457.457 0 0 0 .368-.189l6.596-8.14a.504.504 0 0 0 .103-.36.516.516 0 0 0-.223-.271z" fill="currentColor" />
<path d="M15.071.653a.457.457 0 0 0-.304-.102.493.493 0 0 0-.381.178l-6.19 7.636-1.2-1.298-.728.897 1.58 1.704a.465.465 0 0 0 .349.158.457.457 0 0 0 .368-.189l6.596-8.14a.504.504 0 0 0 .103-.36.516.516 0 0 0-.193-.484z" fill="currentColor" />
</svg>
);
}
/** Renders the appropriate status icon for a given MessageStatus. */
export function MessageStatusIcon({ status }: { status: MessageStatus }) {
switch (status) {
case "sending":
case "sent":
return <span className="text-wa-delivered opacity-75"><CheckIcon /></span>;
case "delivered":
return <span className="text-wa-delivered"><DoubleCheckIcon /></span>;
case "read":
return <span className="text-wa-read"><DoubleCheckIcon /></span>;
case "failed":
return (
<span className="text-wa-text-critical">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg>
</span>
);
}
}
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%;
}