Skip to content
Motion
FreeIn reviewv1.0.0

Gallery Masonry

Images at their own proportions in balanced columns, so portraits and landscapes sit together.

npx shadcn@latest add @vibebox/gallery-masonry

Controls

Variant
<GalleryMasonry />

About

A free alternative to gallery-grid for photos of mixed shapes (portfolios, events).

Use it when

  • Photos come in portrait and landscape.

Skip it when

  • Order across rows matters (it reads down each column): use gallery-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/gallery-masonry
  3. 3Use it:
    import { GalleryMasonry } from "@/components/vibebox/gallery-masonry";
    import { home } from "@/content/pages/home";
    
    <GalleryMasonry content={home.gallery} variant="three-up" id="gallery" />

Code

2 files
components/vibebox/gallery-masonry/index.tsx
/**
 * VibeBox Motion · Gallery Masonry
 * 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, Media, REVEAL, Section, SectionHead, SlotText, type BlockProps } from "@/lib/vibebox/block-kit";
import { cn } from "@/lib/utils";
import type { Content, Variant } from "./schema";

/*
 * Images at their own proportions in balanced columns (CSS columns, no script), so portraits and
 * landscapes sit together naturally. Placeholder scenes alternate tall and wide.
 */

const SCENE_RATIOS = ["4 / 5", "4 / 3", "1 / 1", "3 / 4", "16 / 10"];

export type GalleryMasonryProps = BlockProps<Content, Variant>;

export function GalleryMasonry({ content, variant = "three-up", id, tone, className }: GalleryMasonryProps) {
  return (
    <Section slug="gallery-masonry" 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} />
        <ul className={cn("columns-1 gap-4 sm:columns-2", variant === "three-up" && "lg:columns-3")}>
          {content.items.map((item, index) => {
            const scene = item.media.src.startsWith("scene:");
            const ratio = scene ? SCENE_RATIOS[index % SCENE_RATIOS.length] : `${item.media.width} / ${item.media.height}`;
            return (
              <li key={index} className={cn("mb-4 break-inside-avoid", REVEAL)}>
                <figure className="m-0 grid gap-2">
                  <div className="overflow-hidden rounded-xl ring-1 ring-border" style={{ aspectRatio: ratio }}>
                    <Media image={item.media} variant={index} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" />
                  </div>
                  <SlotText as="figcaption" value={item.caption} path={`items.${index}.caption`} className="text-small text-muted-foreground" />
                </figure>
              </li>
            );
          })}
        </ul>
      </Container>
    </Section>
  );
}
components/vibebox/gallery-masonry/schema.ts
/**
 * VibeBox Motion · Gallery Masonry
 * 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 = ["three-up", "two-up"] as const;

export type Variant = (typeof variants)[number];

export const content = sections.gallery;

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—
itemsImageslistup to 12—
items[].mediaImageimage——
items[].captionCaptiontext60 charsCan be a fill-in

Variants

  • Three columnsdefault

    three-up

    One column on phones, two on tablets, three on desktop.

  • Two columns

    two-up

    One column on phones, two from tablets.

Accessibility and motion

Keyboard
Nothing to focus.
Screen readers
A heading and a list of figures in reading order (top to bottom per column).
Touch
Nothing to touch.
Reduced motion
Images appear without their reveal.

Edge cases and limits

  • Scenes alternate tall and wide so the columns look natural before photos arrive.
  • Six square scenes in a three-column gallery with captions

    Photos of the work or the place in an even grid or with the first image large, with captions.

    Gallery block · Server

  • A row of photos, the centre one facing forward and the others turned away

    A swipeable row that snaps one photo at a time, each turning in 3D as it leaves the centre.

    Gallery block · Server

  • A living room photo split by a liquid seam between before and after

    Before and after, compared by dragging a liquid seam across each photo, then the rest of the gallery.

    Gallery block · WebGL2

↑ ↓ to move↵ to openesc to close