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-cardsControls
<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
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the block with the shadcn CLI:
npx shadcn@latest add @vibebox/contact-cards - 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 filescomponents/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.
| Slot | Label | Type | Limit | Fact |
|---|---|---|---|---|
| eyebrow | Kicker | text | 32 chars | — |
| heading | Heading | text | 60 chars | — |
| lead | Intro | text | 160 chars | — |
| channels | Ways to reach you | list | up to 6 | — |
| channels[].kind | Channel | choice | — | — |
| channels[].label | Label | text | 24 chars | — |
| channels[].value | Value | fact | 120 chars | Yes, 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.
Related

Contact Strip
FreeA compact contact band: the heading, every channel on one line and one action.
Contact block · Server

Split Contact
FreeEvery way to reach the business beside an inquiry form that works without JavaScript; facts are fill-ins until given.
Contact block · Server

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