Skip to content
Motion
FreeIn reviewv1.0.0

Contact Cards

One card per way to reach the business (call, visit, message), or a list of rows; facts until given.

npx shadcn@latest add @vibebox/contact-cards

Controls

Variant
<ContactCards />

About

Rebuilds the site kit's three-card contact layout from a list of channels, so it fits any number from one to six.

Use it when

  • The business wants calls and messages more than form inquiries.

Skip it when

  • Inquiries need a form: use contact-split.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the block with the shadcn CLI:
    npx shadcn@latest add @vibebox/contact-cards
  3. 3Use it:
    import { ContactCards } from "@/components/vibebox/contact-cards";
    import { home } from "@/content/pages/home";
    
    <ContactCards content={home.contact} variant="cards" id="contact" />

Code

2 files
components/vibebox/contact-cards/index.tsx
/**
 * VibeBox Motion · Contact Cards
 * 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.
 */
import { ChannelValue, CHANNEL_ICONS, Container, headingId, Icon, REVEAL, Section, SectionHead, SlotText, type BlockProps } from "@/lib/vibebox/block-kit";
import { cn } from "@/lib/utils";
import type { Content, Variant } from "./schema";

/*
 * One card per way to reach the business (call, visit, message), or a list of rows. Each value
 * is a link when it can be (tel:, mailto:, chat apps, maps) and a fill-in until given.
 */

export type ContactCardsProps = BlockProps<Content, Variant>;

export function ContactCards({ content, variant = "cards", id, tone, className }: ContactCardsProps) {
  const list = variant === "list";
  return (
    <Section slug="contact-cards" variant={variant} id={id} tone={tone} labelledBy={headingId(id)} className={className}>
      <Container className={cn("grid gap-[clamp(32px,4vw,56px)]", list && "lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] lg:gap-20")}>
        <SectionHead eyebrow={content.eyebrow} heading={content.heading} lead={content.lead} id={id} />
        <ul className={cn(list ? "grid border-t border-border" : "grid gap-4 sm:grid-cols-2 lg:grid-cols-3")}>
          {content.channels.map((channel, index) => (
            <li
              key={index}
              className={cn(
                list ? "grid grid-cols-[auto_minmax(0,1fr)] items-start gap-4 border-b border-border py-5" : "grid content-start gap-4 rounded-xl bg-card p-6 text-card-foreground ring-1 ring-border",
                REVEAL,
              )}
            >
              <span className="inline-grid size-11 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--spectrum-2)_12%,var(--card))] ring-1 ring-border">
                <Icon name={CHANNEL_ICONS[channel.kind]} />
              </span>
              <div className="grid min-w-0 gap-1">
                <SlotText value={channel.label} path={`channels.${index}.label`} className="text-small text-muted-foreground" />
                <ChannelValue channel={channel} path={`channels.${index}.value`} className={cn("font-semibold", !list && "text-[1.1rem]")} />
              </div>
            </li>
          ))}
        </ul>
      </Container>
    </Section>
  );
}
components/vibebox/contact-cards/schema.ts
/**
 * VibeBox Motion · Contact Cards
 * 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.
 */
import { sections, type z } from "@/lib/vibebox/content";

export const variants = ["cards", "list"] as const;

export type Variant = (typeof variants)[number];

export const content = sections.contact.omit({ form: true, media: true });

export type Content = z.infer<typeof content>;

Content

Typed slots from schema.ts. Facts (hours, prices, quotes, names) come only from the business; until then they render as fill-ins.

SlotLabelTypeLimitFact
eyebrowKickertext32 chars—
headingHeadingtext60 chars—
leadIntrotext160 chars—
channelsWays to reach youlistup to 6—
channels[].kindChannelchoice——
channels[].labelLabeltext24 chars—
channels[].valueValuefact120 charsYes, a fill-in until given

Variants

  • Cardsdefault

    cards

    Up to three cards across.

  • List

    list

    Rows beside the heading on desktop.

Accessibility and motion

Keyboard
Each channel's link.
Screen readers
A heading and a list of channels, each a label and a link.
Touch
Each value is a large tap target.
Reduced motion
Cards appear without their reveal.

Edge cases and limits

  • Long emails and addresses wrap within the card.
  • A compact card with a heading, four contact channels and a Book a visit button

    A compact contact band: the heading, every channel on one line and one action.

    Contact block · Server

  • Contact channels with icons and a map scene beside an inquiry form

    Every way to reach the business beside an inquiry form that works without JavaScript; facts are fill-ins until given.

    Contact block · Server

  • A chat panel with a greeting and three quick questions beside contact channels

    Contact as a conversation: a chat panel whose quick questions open the business's chat app, prefilled.

    Contact block · Server

↑ ↓ to move↵ to openesc to close