Services Bento
The lead service as a large image card, the rest as a numbered list beside it or as cards beneath.
npx shadcn@latest add @vibebox/services-bentoControls
<ServicesBento />About
Rebuilds the site kit's bento features. It suits businesses with one main service and a few more (a clinic's check-ups, a studio's flagship class). It differs from services-icon-grid (equal cards) and services-zigzag (a photo per service).
Use it when
- One service leads and the rest support it.
- There is one good photo for the lead service.
Skip it when
- Every service matters equally: use
services-icon-grid. - Each service has its own photo: use
services-zigzag.
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/services-bento - 3Use it:
import { ServicesBento } from "@/components/vibebox/services-bento"; import { home } from "@/content/pages/home"; <ServicesBento content={home.services} variant="feature-left" id="services" />
Code
2 filescomponents/vibebox/services-bento/index.tsx
/**
* VibeBox Motion · Services Bento
* 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 { Container, headingId, Icon, Media, REVEAL, Section, SectionHead, SlotText, TextLink, type BlockProps } from "@/lib/vibebox/block-kit";
import type { Image } from "@/lib/vibebox/content";
import { cn } from "@/lib/utils";
import type { Content, Variant } from "./schema";
/*
* The lead service as a large card with an image, and the rest as a numbered list beside it
* (feature-left) or as cards beneath it (feature-top).
*/
const FALLBACK: Image = { src: "scene:bloom", alt: "", width: 1200, height: 900 };
const pad = (n: number) => String(n).padStart(2, "0");
type Item = Content["items"][number];
function Mark({ n, icon }: { n: number; icon?: Item["icon"] }) {
return icon ? (
<span className="inline-grid size-10 place-items-center rounded-md border border-border bg-background text-foreground">
<Icon name={icon} />
</span>
) : (
<span aria-hidden="true" className="font-mono text-small font-medium text-muted-foreground tabular-nums">
{pad(n)}
</span>
);
}
export type ServicesBentoProps = BlockProps<Content, Variant>;
export function ServicesBento({ content, variant = "feature-left", id, tone, className }: ServicesBentoProps) {
const [first, ...rest] = content.items;
const top = variant === "feature-top";
if (!first) return null;
return (
<Section slug="services-bento" variant={variant} id={id} tone={tone} labelledBy={headingId(id)} className={className}>
<Container className="grid gap-[clamp(32px,4vw,56px)]">
<SectionHead eyebrow={content.eyebrow} heading={content.heading} lead={content.lead} id={id} />
<div className={cn("grid grid-cols-[minmax(0,1fr)] gap-4", !top && "lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]")}>
<article className={cn("grid overflow-hidden rounded-xl bg-card text-card-foreground ring-1 ring-border", top && "md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]", REVEAL)}>
<div className={cn("relative", top ? "aspect-[16/10] md:aspect-auto md:min-h-[320px]" : "aspect-[16/10]")}>
<Media image={first.media ?? FALLBACK} sizes="(min-width: 1024px) 45vw, 100vw" />
</div>
<div className="grid content-end gap-3 p-6 sm:p-8">
<Mark n={1} icon={first.icon} />
<SlotText as="h3" value={first.title} path="items.0.title" className="font-display text-[clamp(1.5rem,1.1rem+1.3vw,2.25rem)] leading-tight font-semibold tracking-[-0.02em]" />
<SlotText as="p" value={first.body} path="items.0.body" className="max-w-[48ch] text-pretty text-muted-foreground" />
{content.action && <TextLink link={content.action} path="action" className="mt-2 justify-self-start" />}
</div>
</article>
{top ? (
<ul className={cn("grid gap-4 sm:grid-cols-2", rest.length >= 3 && "lg:grid-cols-3", rest.length === 4 && "lg:grid-cols-4")}>
{rest.map((item, index) => (
<li key={index} className={cn("grid content-start gap-3 rounded-xl bg-card p-6 text-card-foreground ring-1 ring-border", REVEAL)}>
<Mark n={index + 2} icon={item.icon} />
<SlotText as="h3" value={item.title} path={`items.${index + 1}.title`} className="text-h3 font-semibold tracking-tight" />
<SlotText as="p" value={item.body} path={`items.${index + 1}.body`} className="text-small text-pretty text-muted-foreground" />
</li>
))}
</ul>
) : (
<ol className="grid content-start border-t border-border">
{rest.map((item, index) => (
<li key={index} className={cn("grid grid-cols-[auto_minmax(0,1fr)] gap-x-5 gap-y-1.5 border-b border-border py-6", REVEAL)}>
<div className="row-span-2 pt-1">
<Mark n={index + 2} icon={item.icon} />
</div>
<SlotText as="h3" value={item.title} path={`items.${index + 1}.title`} className="text-h3 font-semibold tracking-tight" />
<SlotText as="p" value={item.body} path={`items.${index + 1}.body`} className="text-pretty text-muted-foreground" />
</li>
))}
</ol>
)}
</div>
</Container>
</Section>
);
}components/vibebox/services-bento/schema.ts
/**
* VibeBox Motion · Services Bento
* 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 = ["feature-left", "feature-top"] as const;
export type Variant = (typeof variants)[number];
export const content = sections.services;
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 | — |
| items | Services | list | up to 6 | — |
| items[].title | Title | text | 40 chars | — |
| items[].body | Description | text | 140 chars | — |
| items[].icon | Icon | icon | — | — |
| items[].media | Image | image | — | — |
| action | Button | action | 24 chars | — |
Variants
Feature leftdefault
feature-left
The lead card left, a numbered list of the rest right.
Feature top
feature-top
The lead card wide on top, the rest as cards beneath.
Accessibility and motion
- Keyboard
- The optional action link.
- Screen readers
- A heading, then each service as a heading and a paragraph, in order.
- Touch
- Nothing depends on hover.
- Reduced motion
- Cards appear without their reveal.
Edge cases and limits
- Six 140-character services: the list grows; the card stays.
- No image on the lead service: a placeholder scene.
Related

Three to six services as cards with icons, an index of numbered rows, or hairline columns.
Services block · Server

The lead service behind reeded glass that clears under the pointer, the rest on frosted cards over a glow.
Services block · WebGL2

Zigzag Services
FreeOne row per service with its own photo, alternating sides; placeholder scenes until photos arrive.
Services block · Server