Skip to content
Motion
FreeIn reviewv1.0.0

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-bento

Controls

Variant
<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

  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/services-bento
  3. 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 files
components/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.

SlotLabelTypeLimitFact
eyebrowKickertext32 chars—
headingHeadingtext60 chars—
leadIntrotext160 chars—
itemsServiceslistup to 6—
items[].titleTitletext40 chars—
items[].bodyDescriptiontext140 chars—
items[].iconIconicon——
items[].mediaImageimage——
actionButtonaction24 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.
  • Six feature cards with line icons for booking, reminders, payments, notes, schedules and reports

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

    Services block · Server

  • A sunset behind reeded glass with a massage service, beside four frosted service cards

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

    Services block · WebGL2

  • Rows for haircuts, colour and treatments, each beside a scene, alternating sides

    One row per service with its own photo, alternating sides; placeholder scenes until photos arrive.

    Services block · Server

↑ ↓ to move↵ to openesc to close