Skip to content
Motion
FreeIn reviewv1.0.0

Contact Bloom

One button that blooms into Call, Text, Viber, Messenger, WhatsApp and email: the channels customers already use.

npx shadcn@latest add @vibebox/contact-bloom

Controls

Blooms
<ContactBloom />

About

The shortest path from "I have a question" to a conversation. One button blooms into the channels a business actually answers: a call, a text, Viber, Messenger, WhatsApp, email. Each is a plain link showing its number or handle, opening the right app on a phone. Only the channels you pass appear, so it never shows a dead option. It sits in the flow or floats in the corner of the screen, blooms up or down, and behaves as a disclosure: the button toggles the list, Escape or a click outside closes it, and focus returns to the button. Made for Philippine businesses, where Viber and Messenger often matter more than a contact form. Original to VibeBox Motion.

Use it when

  • A local business takes inquiries by phone or chat apps.
  • A page needs one compact contact action that works on phones.

Skip it when

  • Inquiries must go through a form (for records or routing); link to the form instead.
  • The business does not answer a channel; leave it out.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/contact-bloom
  3. 3Use it:
    import { ContactBloom } from "@/components/vibebox/contact-bloom";
    
    const channels = { phone: "0917 555 0142", viber: "+63 917 555 0142", messenger: "northlinedental", email: "hello@northline.ph" };
    
    <ContactBloom channels={channels} position="fixed" />

Code

2 files
components/vibebox/contact-bloom/contact-bloom.client.tsx
/**
 * VibeBox Motion · Contact Bloom
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
"use client";

import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { cn } from "@/lib/utils";

/*
 * One button that blooms into the ways customers actually reach a business: call, text,
 * Viber, Messenger, WhatsApp and email. Only the channels you pass appear. It is a disclosure:
 * the button toggles a list of plain links, Escape or a click outside closes it and returns
 * focus, and closed links leave the tab order. The bloom is CSS (transform and opacity).
 */

export type ContactChannels = {
  /** A phone number to call, as written ("0917 123 4567" or "+63 917 123 4567"). */
  phone?: string;
  /** A number for text messages. */
  sms?: string;
  /** A Viber number, in international format. */
  viber?: string;
  /** A Messenger username or page id (the part after m.me/). */
  messenger?: string;
  /** A WhatsApp number, in international format. */
  whatsapp?: string;
  email?: string;
};

type Channel = { key: keyof ContactChannels; label: string; detail: string; href: string; icon: ReactNode };

const digits = (value: string) => value.replace(/[^\d+]/g, "");

const ICON = {
  phone: <path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2Z" />,
  message: <path d="M4 5h16v11H8l-4 3.5V5Z" />,
  chat: <path d="M12 4c4.6 0 8 3.1 8 7s-3.4 7-8 7c-1 0-2-.1-2.9-.4L5 19l1.1-3.3A6.6 6.6 0 0 1 4 11c0-3.9 3.4-7 8-7Z" />,
  mail: <path d="M4 6h16v12H4zM4 7l8 6 8-6" />,
};

function channelsFrom(channels: ContactChannels): Channel[] {
  const list: Channel[] = [];
  if (channels.phone) list.push({ key: "phone", label: "Call", detail: channels.phone, href: `tel:${digits(channels.phone)}`, icon: ICON.phone });
  if (channels.sms) list.push({ key: "sms", label: "Text", detail: channels.sms, href: `sms:${digits(channels.sms)}`, icon: ICON.message });
  if (channels.viber) list.push({ key: "viber", label: "Viber", detail: channels.viber, href: `viber://chat?number=${encodeURIComponent(digits(channels.viber))}`, icon: ICON.chat });
  if (channels.messenger) list.push({ key: "messenger", label: "Messenger", detail: `m.me/${channels.messenger}`, href: `https://m.me/${encodeURIComponent(channels.messenger)}`, icon: ICON.chat });
  if (channels.whatsapp) list.push({ key: "whatsapp", label: "WhatsApp", detail: channels.whatsapp, href: `https://wa.me/${digits(channels.whatsapp).replace(/^\+/, "")}`, icon: ICON.chat });
  if (channels.email) list.push({ key: "email", label: "Email", detail: channels.email, href: `mailto:${channels.email}`, icon: ICON.mail });
  return list;
}

export type ContactBloomProps = {
  channels: ContactChannels;
  /** The button's text. */
  label?: string;
  /** "inline" sits in the flow; "fixed" floats in the bottom corner of the screen. */
  position?: "inline" | "fixed";
  /** Which way the channels bloom from the button. */
  direction?: "up" | "down";
  className?: string;
};

export function ContactBloom({ channels, label = "Contact us", position = "inline", direction = "up", className }: ContactBloomProps) {
  const [open, setOpen] = useState(false);
  const rootRef = useRef<HTMLDivElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const listId = useId();
  const list = channelsFrom(channels);

  useEffect(() => {
    if (!open) return;
    const onKey = (event: KeyboardEvent) => {
      if (event.key !== "Escape") return;
      setOpen(false);
      buttonRef.current?.focus();
    };
    const onPointer = (event: PointerEvent) => {
      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
    };
    document.addEventListener("keydown", onKey);
    document.addEventListener("pointerdown", onPointer);
    return () => {
      document.removeEventListener("keydown", onKey);
      document.removeEventListener("pointerdown", onPointer);
    };
  }, [open]);

  if (!list.length) return null;

  return (
    <div
      ref={rootRef}
      data-vb="contact-bloom"
      data-state={open ? "open" : "closed"}
      className={cn("relative inline-flex", position === "fixed" && "fixed right-4 bottom-4 z-50", className)}
    >
      <button
        ref={buttonRef}
        type="button"
        aria-expanded={open}
        aria-controls={listId}
        onClick={() => setOpen((value) => !value)}
        className="group inline-flex h-12 items-center gap-2.5 rounded-full bg-primary pr-5 pl-4 font-medium text-primary-foreground shadow-[0_12px_30px_-14px_rgb(0_0_0/0.55)] transition-transform duration-200 ease-expo-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring active:scale-[0.97]"
      >
        <span aria-hidden="true" className="relative grid size-6 place-items-center">
          <svg viewBox="0 0 24 24" className="size-5 transition-[transform,opacity] duration-300 group-aria-expanded:scale-50 group-aria-expanded:rotate-90 group-aria-expanded:opacity-0" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round">
            {ICON.chat}
          </svg>
          <svg viewBox="0 0 24 24" className="absolute size-5 scale-50 -rotate-90 opacity-0 transition-[transform,opacity] duration-300 group-aria-expanded:scale-100 group-aria-expanded:rotate-0 group-aria-expanded:opacity-100" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
            <path d="M6 6l12 12M18 6 6 18" />
          </svg>
        </span>
        {label}
      </button>
      <ul
        id={listId}
        aria-label={label}
        // Floats over the page, nearest channel first, so the button never moves.
        className={cn("absolute right-0 flex w-max gap-2", direction === "up" ? "bottom-full mb-3 flex-col-reverse" : "top-full mt-3 flex-col")}
      >
        {list.map((channel, index) => (
          <li
            key={channel.key}
            data-vb-cb-item=""
            style={{ "--vb-cb-i": index } as CSSProperties}
            className={cn(
              "flex justify-end transition-[opacity,transform,visibility] duration-300 ease-expo-out motion-reduce:transform-none",
              direction === "up" ? "origin-bottom-right" : "origin-top-right",
              open
                ? "visible translate-y-0 scale-100 opacity-100 delay-[calc(var(--vb-cb-i)*45ms)]"
                : cn("invisible scale-90 opacity-0", direction === "up" ? "translate-y-3" : "-translate-y-3"),
            )}
          >
            <a
              href={channel.href}
              className="flex min-h-11 items-center gap-3 rounded-full border border-border bg-popover py-1.5 pr-4 pl-1.5 text-popover-foreground shadow-[0_10px_24px_-16px_rgb(0_0_0/0.5)] transition-colors duration-150 hover:border-border-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
            >
              <span aria-hidden="true" className="grid size-8 shrink-0 place-items-center rounded-full bg-spectrum text-white">
                <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                  {channel.icon}
                </svg>
              </span>
              <span className="flex flex-col leading-tight">
                <span className="text-small font-medium">{channel.label}</span>
                <span className="text-caption text-muted-foreground">{channel.detail}</span>
              </span>
            </a>
          </li>
        ))}
      </ul>
    </div>
  );
}
components/vibebox/contact-bloom/index.tsx
/**
 * VibeBox Motion · Contact Bloom
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
export { ContactBloom, type ContactBloomProps, type ContactChannels } from "./contact-bloom.client";

Props

PropTypeDefaultDescription
channels *ContactChannels—{ phone, sms, viber, messenger, whatsapp, email }: only the ones you pass appear.
labelstring"Contact us"The button's text.
position"inline" | "fixed""inline"In the flow, or floating in the bottom corner.
direction"up" | "down""up"Which way the channels bloom.
classNamestring—Merged with cn().

Accessibility and motion

Keyboard
The button toggles the list (aria-expanded); Tab moves through the channel links; Escape closes and returns focus to the button.
Screen readers
A button and a labelled list of links, each named by channel with its number or address.
Touch
Links are at least 44 px tall; a tap outside closes the list.
Reduced motion
The channels fade in and out without moving.

Edge cases and limits

  • viber:// and sms: links open apps on phones; on desktops without those apps they may do nothing, so keep a phone or email channel too.
  • A Messenger handle is the part after m.me/.
  • A month calendar with open days marked and a grid of appointment times

    A date and time-slot picker with a keyboard grid; availability arrives as props, so any booking back end fits.

    Forms and booking · CSS

↑ ↓ to move↵ to openesc to close