Zigzag Services
One row per service with its own photo, alternating sides; placeholder scenes until photos arrive.
npx shadcn@latest add @vibebox/services-zigzagControls
<ServicesZigzag />About
Rebuilds the site kit's zigzag rows. Suits salons, studios and contractors whose services are visual. It differs from services-bento (one image) and services-icon-grid (no images).
Use it when
- Each service has, or will have, its own photo.
- Three or four services.
Skip it when
- Six short services: the page gets long; use
services-icon-grid.
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-zigzag - 3Use it:
import { ServicesZigzag } from "@/components/vibebox/services-zigzag"; import { home } from "@/content/pages/home"; <ServicesZigzag content={home.services} variant="media-first" id="services" />
Code
2 filescomponents/vibebox/services-zigzag/index.tsx
/**
* VibeBox Motion · Zigzag Services
* 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, SceneName } from "@/lib/vibebox/content";
import { cn } from "@/lib/utils";
import type { Content, Variant } from "./schema";
/*
* One row per service, image and copy alternating sides, for services that deserve a photo each.
* Missing photos become placeholder scenes, each in a different arrangement of the palette.
*/
const SCENES: SceneName[] = ["arches", "waves", "blocks", "orbit", "petals", "horizon"];
const pad = (n: number) => String(n).padStart(2, "0");
export type ServicesZigzagProps = BlockProps<Content, Variant>;
export function ServicesZigzag({ content, variant = "media-first", id, tone, className }: ServicesZigzagProps) {
return (
<Section slug="services-zigzag" variant={variant} id={id} tone={tone} labelledBy={headingId(id)} className={className}>
<Container className="grid gap-[clamp(40px,5vw,72px)]">
<SectionHead eyebrow={content.eyebrow} heading={content.heading} lead={content.lead} id={id} />
<ol className="grid gap-[clamp(40px,6vw,88px)]">
{content.items.map((item, index) => {
const flip = (index % 2 === 1) !== (variant === "copy-first");
const fallback: Image = { src: `scene:${SCENES[index % SCENES.length]}:${index}`, alt: "", width: 1200, height: 900 };
return (
<li key={index} className="grid grid-cols-[minmax(0,1fr)] items-center gap-6 md:grid-cols-2 md:gap-[clamp(32px,5vw,72px)]">
<div className={cn("aspect-[4/3] overflow-hidden rounded-xl ring-1 ring-border", flip && "md:order-2", REVEAL)}>
<Media image={item.media ?? fallback} sizes="(min-width: 768px) 50vw, 100vw" />
</div>
<div className={cn("grid content-center gap-4", REVEAL)}>
<span className="flex items-center gap-3">
{item.icon && (
<span className="inline-grid size-10 place-items-center rounded-md border border-border bg-card">
<Icon name={item.icon} />
</span>
)}
<span aria-hidden="true" className="font-mono text-small font-medium text-muted-foreground tabular-nums">
{pad(index + 1)}
</span>
</span>
<SlotText as="h3" value={item.title} path={`items.${index}.title`} className="font-display text-[clamp(1.6rem,1.1rem+1.6vw,2.6rem)] leading-tight font-semibold tracking-[-0.025em] text-balance" />
<SlotText as="p" value={item.body} path={`items.${index}.body`} className="max-w-[46ch] text-pretty text-muted-foreground" />
{content.action && index === 0 && <TextLink link={content.action} path="action" className="mt-1 justify-self-start" />}
</div>
</li>
);
})}
</ol>
</Container>
</Section>
);
}components/vibebox/services-zigzag/schema.ts
/**
* VibeBox Motion · Zigzag Services
* 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 = ["media-first", "copy-first"] 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
Image firstdefault
media-first
The first row starts with the image on the left; rows alternate.
Copy first
copy-first
The first row starts with the copy on the left; rows alternate.
Accessibility and motion
- Keyboard
- The optional action link in the first row.
- Screen readers
- A heading, then each service as a heading and a paragraph with its image.
- Touch
- Nothing depends on hover.
- Reduced motion
- Rows appear without their reveal.
Edge cases and limits
- Six services make a long section; prefer three or four.
- Missing photos: each row gets a different scene.
Related

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

Services Bento
FreeThe lead service as a large image card, the rest as a numbered list beside it or as cards beneath.
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