Skip to content
Motion
FreeIn reviewv1.0.0

Zigzag Services

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

npx shadcn@latest add @vibebox/services-zigzag

Controls

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

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

SlotLabelTypeLimitFact
eyebrowKickertext32 chars—
headingHeadingtext60 chars—
leadIntrotext160 chars—
itemsServiceslistup to 6—
items[].titleTitletext40 chars—
items[].bodyDescriptiontext140 chars—
items[].iconIconicon——
items[].mediaImageimage——
actionButtonaction24 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.
  • 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 large card for cleanings with a bloom scene beside a numbered list of three more treatments

    The lead service as a large image card, the rest as a numbered list beside it or as cards beneath.

    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

↑ ↓ to move↵ to openesc to close