Gallery

Responsive grid for displaying images and media.

import { Gallery } from "ui-lab-components"
 
const items = [
  { id: 1, title: "Mountain", image: "...", href: "#" },
  { id: 2, title: "Ocean", image: "...", href: "#" },
  { id: 3, title: "Forest", image: "...", href: "#" },
]
 
export function Example() {
  return (
    <Gallery columns={3}>
      {items.map((item) => (
        <Gallery.Item key={item.id} href={item.href}>
          <Gallery.View aspectRatio="16/9">
            <img src={item.image} alt={item.title} />
          </Gallery.View>
          <Gallery.Body>
            <strong>{item.title}</strong>
          </Gallery.Body>
        </Gallery.Item>
      ))}
    </Gallery>
  )
}

Grid Composition

Gallery items arranged in a configurable grid with consistent gap and aspect ratio.

1024px
"use client";
 
import React from 'react'
import { Gallery, Frame } from 'ui-lab-components'
import type { GalleryProps } from 'ui-lab-components'
import type { ControlDef } from '@/types'
 
type GalleryGap = NonNullable<GalleryProps['gap']>;
type ItemTier = 'common' | 'archived' | 'experimental';
 
const FRAME_STYLE: Record<ItemTier, React.CSSProperties> = {
  common: { '--frame-fill': 'var(--background-900)', '--frame-stroke-color': 'var(--background-600)' } as React.CSSProperties,
  archived: { '--frame-fill': 'var(--background-950)', '--frame-stroke-color': 'var(--background-700)' } as React.CSSProperties,
  experimental: { '--frame-fill': 'var(--background-950)', '--frame-stroke-color': 'var(--background-800)' } as React.CSSProperties,
};
 
export const controls: ControlDef[] = [
  { name: 'columns', label: 'Columns', type: 'stepper', defaultValue: 3, min: 1, max: 6, step: 1 },
  {
    name: 'gap',
    label: 'Gap Token',
    type: 'select',
    options: [
      { label: 'Extra Small', value: 'xs' },
      { label: 'Small', value: 'sm' },
      { label: 'Medium', value: 'md' },
      { label: 'Large', value: 'lg' },
      { label: 'Extra Large', value: 'xl' },
    ],
    defaultValue: 'md',
  },
  {
    name: 'aspectRatio',
    label: 'Aspect Ratio',
    type: 'select',
    options: [
      { label: '1:1', value: '1/1' },
      { label: '4:3', value: '4/3' },
      { label: '3:4', value: '3/4' },
      { label: '16:9', value: '16/9' },
    ],
    defaultValue: '4/3',
  },
  { name: 'itemCount', label: 'Items', type: 'stepper', defaultValue: 4, min: 4, max: 12, step: 1 },
  { name: 'responsive', label: 'Container-Query Responsive', type: 'toggle', defaultValue: false },
];
 
export const previewLayout = 'start' as const;
export const resizable = true;
 
const GRID_ITEMS = [
  { id: 'g1', title: 'Brand Kit', description: 'Identity · Updated 2d ago', tier: 'common' as ItemTier },
  { id: 'g2', title: 'Icon Set', description: 'UI Assets · 142 icons', tier: 'common' as ItemTier },
  { id: 'g3', title: 'Type Scale', description: 'Typography · 8 weights', tier: 'common' as ItemTier },
  { id: 'g4', title: 'Color Tokens', description: 'Design System · v3.2', tier: 'common' as ItemTier },
  { id: 'g5', title: 'Grid Spec', description: 'Layout · 12-column', tier: 'common' as ItemTier },
  { id: 'g6', title: 'Motion Guide', description: 'Animation · 24 presets', tier: 'common' as ItemTier },
  { id: 'g7', title: 'Legacy Icons', description: 'Archived · v1.4', tier: 'archived' as ItemTier },
  { id: 'g8', title: 'Old Palette', description: 'Archived · 2021', tier: 'archived' as ItemTier },
  { id: 'g9', title: 'Beta Components', description: 'Archived · Pre-release', tier: 'archived' as ItemTier },
  { id: 'g10', title: 'Prototype A', description: 'Experimental · Do not ship', tier: 'experimental' as ItemTier },
  { id: 'g11', title: 'Prototype B', description: 'Experimental · Internal only', tier: 'experimental' as ItemTier },
  { id: 'g12', title: 'Prototype C', description: 'Experimental · Unreleased', tier: 'experimental' as ItemTier },
];
 
function getGap(value: unknown): GalleryGap {
  if (value === 'xs' || value === 'sm' || value === 'lg' || value === 'xl') return value;
  return 'md';
}
 
function getInt(value: unknown, min: number, max: number, fallback: number) {
  const n = Number(value);
  if (Number.isNaN(n)) return fallback;
  return Math.min(max, Math.max(min, Math.round(n)));
}
 
function getAspectRatio(value: unknown): string {
  if (typeof value === 'string' && value.includes('/')) return value;
  return '4/3';
}
 
function itemClassName(tier: ItemTier) {
  if (tier === 'archived') return 'opacity-60';
  if (tier === 'experimental') return 'opacity-35';
  return undefined;
}
 
export function renderPreview(props: Record<string, unknown>) {
  const columns = getInt(props.columns, 1, 6, 3);
  const gap = getGap(props.gap);
  const ratio = getAspectRatio(props.aspectRatio);
  const count = getInt(props.itemCount, 4, 12, 4);
  const responsive = Boolean(props.responsive);
  const items = GRID_ITEMS.slice(0, count);
  const resolvedColumns: GalleryProps['columns'] = responsive ? { sm: 1, md: Math.min(2, columns), lg: columns } : columns;
 
  return (
    <Gallery columns={resolvedColumns} gap={gap} responsive={responsive} className="w-full">
      {items.map((item) => (
        <Gallery.Item key={item.id} className={itemClassName(item.tier)} aria-disabled={item.tier === 'experimental' ? true : undefined}>
          <Gallery.View aspectRatio={ratio}>
            <Frame pathStroke={item.tier === 'experimental' ? 'dotted' : 'dashed'} style={FRAME_STYLE[item.tier]} className="w-full h-full" />
          </Gallery.View>
          <Gallery.Body>
            <span>{item.title}</span>
            <span>{item.description}</span>
          </Gallery.Body>
        </Gallery.Item>
      ))}
    </Gallery>
  );
}
 
export default function Example() {
  return renderPreview({ columns: 3, gap: 'md', aspectRatio: '4/3', itemCount: 4, responsive: false });
}

Item Orientation

Gallery items can be oriented vertically (stacked view + body) or horizontally (side-by-side view + body).

1024px
"use client";
 
import React from 'react'
import { Gallery, Frame } from 'ui-lab-components'
import type { GalleryProps } from 'ui-lab-components'
import type { ControlDef } from '@/types'
 
type GalleryGap = NonNullable<GalleryProps['gap']>;
type ItemTier = 'common' | 'archived' | 'experimental';
 
const FRAME_STYLE: Record<ItemTier, React.CSSProperties> = {
  common: { '--frame-fill': 'var(--background-900)', '--frame-stroke-color': 'var(--background-600)' } as React.CSSProperties,
  archived: { '--frame-fill': 'var(--background-950)', '--frame-stroke-color': 'var(--background-700)' } as React.CSSProperties,
  experimental: { '--frame-fill': 'var(--background-950)', '--frame-stroke-color': 'var(--background-800)' } as React.CSSProperties,
};
 
export const controls: ControlDef[] = [
  {
    name: 'orientation',
    label: 'Orientation',
    type: 'select',
    options: [
      { label: 'Vertical', value: 'vertical' },
      { label: 'Horizontal', value: 'horizontal' },
    ],
    defaultValue: 'vertical',
  },
  { name: 'columns', label: 'Columns (vertical only)', type: 'stepper', defaultValue: 2, min: 1, max: 4, step: 1 },
  {
    name: 'gap',
    label: 'Gap Token',
    type: 'select',
    options: [
      { label: 'Extra Small', value: 'xs' },
      { label: 'Small', value: 'sm' },
      { label: 'Medium', value: 'md' },
      { label: 'Large', value: 'lg' },
    ],
    defaultValue: 'sm',
  },
  {
    name: 'aspectRatio',
    label: 'View Aspect Ratio (vertical only)',
    type: 'select',
    options: [
      { label: '1:1', value: '1/1' },
      { label: '4:3', value: '4/3' },
      { label: '3:4', value: '3/4' },
      { label: '16:9', value: '16/9' },
    ],
    defaultValue: '4/3',
  },
  { name: 'itemCount', label: 'Items', type: 'stepper', defaultValue: 4, min: 4, max: 12, step: 1 },
];
 
export const previewLayout = 'start' as const;
export const resizable = true;
 
const ORIENTATION_ITEMS = [
  { id: 'o1', title: 'Waveform Study', description: '12:34 · Electronic', tier: 'common' as ItemTier },
  { id: 'o2', title: 'Threshold', description: '8:02 · Ambient', tier: 'common' as ItemTier },
  { id: 'o3', title: 'Parallel Lines', description: '5:47 · Minimal', tier: 'common' as ItemTier },
  { id: 'o4', title: 'Resonance', description: '9:15 · Drone', tier: 'common' as ItemTier },
  { id: 'o5', title: 'Liminal Space', description: '11:20 · Experimental', tier: 'common' as ItemTier },
  { id: 'o6', title: 'Undertow', description: '7:45 · Ambient', tier: 'common' as ItemTier },
  { id: 'o7', title: 'Archive Vol. I', description: 'Archived · 2019 sessions', tier: 'archived' as ItemTier },
  { id: 'o8', title: 'Archive Vol. II', description: 'Archived · Rough cuts', tier: 'archived' as ItemTier },
  { id: 'o9', title: 'Archive Vol. III', description: 'Archived · Unmixed', tier: 'archived' as ItemTier },
  { id: 'o10', title: 'Session X-01', description: 'Experimental · Unreleased draft', tier: 'experimental' as ItemTier },
  { id: 'o11', title: 'Session X-02', description: 'Experimental · Internal', tier: 'experimental' as ItemTier },
  { id: 'o12', title: 'Session X-03', description: 'Experimental · Do not distribute', tier: 'experimental' as ItemTier },
];
 
function getGap(value: unknown): GalleryGap {
  if (value === 'xs' || value === 'sm' || value === 'lg' || value === 'xl') return value;
  return 'md';
}
 
function getInt(value: unknown, min: number, max: number, fallback: number) {
  const n = Number(value);
  if (Number.isNaN(n)) return fallback;
  return Math.min(max, Math.max(min, Math.round(n)));
}
 
function getAspectRatio(value: unknown): string {
  if (typeof value === 'string' && value.includes('/')) return value;
  return '4/3';
}
 
function getOrientation(value: unknown): 'vertical' | 'horizontal' {
  return value === 'horizontal' ? 'horizontal' : 'vertical';
}
 
function itemClassName(tier: ItemTier) {
  if (tier === 'archived') return 'opacity-60';
  if (tier === 'experimental') return 'opacity-35';
  return undefined;
}
 
export function renderPreview(props: Record<string, unknown>) {
  const orientation = getOrientation(props.orientation);
  const columns = getInt(props.columns, 1, 4, 2);
  const gap = getGap(props.gap);
  const ratio = getAspectRatio(props.aspectRatio);
  const count = getInt(props.itemCount, 4, 12, 4);
  const items = ORIENTATION_ITEMS.slice(0, count);
  const resolvedColumns: GalleryProps['columns'] = orientation === 'horizontal' ? 1 : columns;
 
  return (
    <Gallery columns={resolvedColumns} gap={gap} className="w-full">
      {items.map((item) => (
        <Gallery.Item key={item.id} orientation={orientation} className={itemClassName(item.tier)} aria-disabled={item.tier === 'experimental' ? true : undefined}>
          <Gallery.View aspectRatio={orientation === 'horizontal' ? '1/1' : ratio}>
            <Frame pathStroke={item.tier === 'experimental' ? 'dotted' : 'dashed'} style={FRAME_STYLE[item.tier]} className="w-full h-full" />
          </Gallery.View>
          <Gallery.Body>
            <span>{item.title}</span>
            <span>{item.description}</span>
          </Gallery.Body>
        </Gallery.Item>
      ))}
    </Gallery>
  );
}
 
export default function Example() {
  return renderPreview({ orientation: 'vertical', columns: 2, gap: 'sm', aspectRatio: '4/3', itemCount: 4 });
}

Span Layout

A featured item spans multiple columns and rows to create an editorial grid layout.

1024px
"use client";
 
import React from 'react'
import { Gallery, Frame } from 'ui-lab-components'
import type { GalleryProps } from 'ui-lab-components'
import type { ControlDef } from '@/types'
 
type GalleryGap = NonNullable<GalleryProps['gap']>;
type ItemTier = 'common' | 'archived' | 'experimental';
 
const FRAME_STYLE: Record<ItemTier, React.CSSProperties> = {
  common: { '--frame-fill': 'var(--background-900)', '--frame-stroke-color': 'var(--background-600)' } as React.CSSProperties,
  archived: { '--frame-fill': 'var(--background-950)', '--frame-stroke-color': 'var(--background-700)' } as React.CSSProperties,
  experimental: { '--frame-fill': 'var(--background-950)', '--frame-stroke-color': 'var(--background-800)' } as React.CSSProperties,
};
 
export const controls: ControlDef[] = [
  { name: 'columns', label: 'Columns', type: 'stepper', defaultValue: 3, min: 2, max: 6, step: 1 },
  {
    name: 'gap',
    label: 'Gap Token',
    type: 'select',
    options: [
      { label: 'Extra Small', value: 'xs' },
      { label: 'Small', value: 'sm' },
      { label: 'Medium', value: 'md' },
      { label: 'Large', value: 'lg' },
    ],
    defaultValue: 'md',
  },
  { name: 'featuredColumnSpan', label: 'Featured Column Span', type: 'stepper', defaultValue: 2, min: 1, max: 4, step: 1 },
  { name: 'featuredRowSpan', label: 'Featured Row Span', type: 'stepper', defaultValue: 2, min: 1, max: 3, step: 1 },
  {
    name: 'featuredAspect',
    label: 'Featured Aspect Ratio',
    type: 'select',
    options: [
      { label: '4:3', value: '4/3' },
      { label: '16:9', value: '16/9' },
      { label: '21:9', value: '21/9' },
      { label: '1:1', value: '1/1' },
    ],
    defaultValue: '16/9',
  },
  { name: 'itemCount', label: 'Items', type: 'stepper', defaultValue: 5, min: 3, max: 12, step: 1 },
];
 
export const previewLayout = 'start' as const;
export const resizable = true;
 
const SPAN_ITEMS = [
  { id: 's1', title: "Editor's Pick", description: 'Featured collection — Spring 2024', tier: 'common' as ItemTier },
  { id: 's2', title: 'Series No. 1', description: 'Monochrome', tier: 'common' as ItemTier },
  { id: 's3', title: 'Series No. 2', description: 'Landscape', tier: 'common' as ItemTier },
  { id: 's4', title: 'Series No. 3', description: 'Portrait', tier: 'common' as ItemTier },
  { id: 's5', title: 'Series No. 4', description: 'Abstract', tier: 'common' as ItemTier },
  { id: 's6', title: 'Series No. 5', description: 'Documentary', tier: 'common' as ItemTier },
  { id: 's7', title: 'Hidden Vol. I', description: 'Archived · Unlisted', tier: 'archived' as ItemTier },
  { id: 's8', title: 'Hidden Vol. II', description: 'Archived · Private', tier: 'archived' as ItemTier },
  { id: 's9', title: 'Hidden Vol. III', description: 'Archived · Limited print', tier: 'archived' as ItemTier },
  { id: 's10', title: 'Vault A', description: 'Experimental · Access restricted', tier: 'experimental' as ItemTier },
  { id: 's11', title: 'Vault B', description: 'Experimental · Internal preview', tier: 'experimental' as ItemTier },
  { id: 's12', title: 'Vault C', description: 'Experimental · Embargoed', tier: 'experimental' as ItemTier },
];
 
function getGap(value: unknown): GalleryGap {
  if (value === 'xs' || value === 'sm' || value === 'lg' || value === 'xl') return value;
  return 'md';
}
 
function getInt(value: unknown, min: number, max: number, fallback: number) {
  const n = Number(value);
  if (Number.isNaN(n)) return fallback;
  return Math.min(max, Math.max(min, Math.round(n)));
}
 
function getAspectRatio(value: unknown): string {
  if (typeof value === 'string' && value.includes('/')) return value;
  return '4/3';
}
 
function itemClassName(tier: ItemTier) {
  if (tier === 'archived') return 'opacity-60';
  if (tier === 'experimental') return 'opacity-35';
  return undefined;
}
 
export function renderPreview(props: Record<string, unknown>) {
  const columns = getInt(props.columns, 2, 6, 3);
  const gap = getGap(props.gap);
  const colSpan = Math.min(getInt(props.featuredColumnSpan, 1, 4, 2), columns);
  const rowSpan = getInt(props.featuredRowSpan, 1, 3, 2);
  const featuredRatio = getAspectRatio(props.featuredAspect);
  const count = getInt(props.itemCount, 3, 12, 5);
  const [featured, ...rest] = SPAN_ITEMS.slice(0, count);
 
  return (
    <Gallery columns={columns} gap={gap} className="w-full">
      <Gallery.Item columnSpan={colSpan} rowSpan={rowSpan}>
        <Gallery.View aspectRatio={featuredRatio}>
          <Frame pathStroke="dashed" style={FRAME_STYLE.common} className="w-full h-full" />
        </Gallery.View>
        <Gallery.Body>
          <span>{featured.title}</span>
          <span>{featured.description}</span>
        </Gallery.Body>
      </Gallery.Item>
      {rest.map((item) => (
        <Gallery.Item key={item.id} className={itemClassName(item.tier)} aria-disabled={item.tier === 'experimental' ? true : undefined}>
          <Gallery.View aspectRatio="4/3">
            <Frame pathStroke={item.tier === 'experimental' ? 'dotted' : 'dashed'} style={FRAME_STYLE[item.tier]} className="w-full h-full" />
          </Gallery.View>
          <Gallery.Body>
            <span>{item.title}</span>
            <span>{item.description}</span>
          </Gallery.Body>
        </Gallery.Item>
      ))}
    </Gallery>
  );
}
 
export default function Example() {
  return renderPreview({ columns: 3, gap: 'md', featuredColumnSpan: 2, featuredRowSpan: 2, featuredAspect: '16/9', itemCount: 5 });
}

Item Orientation

Gallery items can be oriented vertically (stacked view + body) or horizontally (side-by-side view + body).

"use client";
 
import type { CSSProperties } from 'react';
import { Gallery, Frame } from 'ui-lab-components';
import type { GalleryProps } from 'ui-lab-components';
 
type ControlDef = {
  name: string;
  label: string;
  type: 'select' | 'toggle' | 'text' | 'stepper';
  options?: Array<{ label: string; value: string | number | boolean }>;
  defaultValue?: string | number | boolean;
  min?: number;
  max?: number;
  step?: number;
};
 
type GalleryGap = NonNullable<GalleryProps['gap']>;
type ItemTier = 'common' | 'archived' | 'experimental';
 
const FRAME_STYLE: Record<ItemTier, CSSProperties> = {
  common: {
    '--frame-fill': 'var(--background-900)',
    '--frame-stroke-color': 'var(--background-600)',
  } as CSSProperties,
  archived: {
    '--frame-fill': 'var(--background-950)',
    '--frame-stroke-color': 'var(--background-700)',
  } as CSSProperties,
  experimental: {
    '--frame-fill': 'var(--background-950)',
    '--frame-stroke-color': 'var(--background-800)',
  } as CSSProperties,
};
 
export const metadata = {
  title: 'Item Orientation',
  description: 'Gallery items can be oriented vertically (stacked view + body) or horizontally (side-by-side view + body).',
  access: 'free' as const,
};
 
export const controls: ControlDef[] = [
  {
    name: 'orientation',
    label: 'Orientation',
    type: 'select',
    options: [
      { label: 'Vertical', value: 'vertical' },
      { label: 'Horizontal', value: 'horizontal' },
    ],
    defaultValue: 'vertical',
  },
  {
    name: 'columns',
    label: 'Columns (vertical only)',
    type: 'stepper',
    defaultValue: 2,
    min: 1,
    max: 4,
    step: 1,
  },
  {
    name: 'gap',
    label: 'Gap Token',
    type: 'select',
    options: [
      { label: 'Extra Small', value: 'xs' },
      { label: 'Small', value: 'sm' },
      { label: 'Medium', value: 'md' },
      { label: 'Large', value: 'lg' },
    ],
    defaultValue: 'sm',
  },
  {
    name: 'aspectRatio',
    label: 'View Aspect Ratio (vertical only)',
    type: 'select',
    options: [
      { label: '1:1', value: '1/1' },
      { label: '4:3', value: '4/3' },
      { label: '3:4', value: '3/4' },
      { label: '16:9', value: '16/9' },
    ],
    defaultValue: '4/3',
  },
  {
    name: 'itemCount',
    label: 'Items',
    type: 'stepper',
    defaultValue: 4,
    min: 4,
    max: 12,
    step: 1,
  },
];
 
export const previewLayout = 'start' as const;
export const resizable = true;
 
const ORIENTATION_ITEMS = [
  { id: 'o1', title: 'Waveform Study', description: '12:34 - Electronic', tier: 'common' as ItemTier },
  { id: 'o2', title: 'Threshold', description: '8:02 - Ambient', tier: 'common' as ItemTier },
  { id: 'o3', title: 'Parallel Lines', description: '5:47 - Minimal', tier: 'common' as ItemTier },
  { id: 'o4', title: 'Resonance', description: '9:15 - Drone', tier: 'common' as ItemTier },
  { id: 'o5', title: 'Liminal Space', description: '11:20 - Experimental', tier: 'common' as ItemTier },
  { id: 'o6', title: 'Undertow', description: '7:45 - Ambient', tier: 'common' as ItemTier },
  { id: 'o7', title: 'Archive Vol. I', description: 'Archived - 2019 sessions', tier: 'archived' as ItemTier },
  { id: 'o8', title: 'Archive Vol. II', description: 'Archived - Rough cuts', tier: 'archived' as ItemTier },
  { id: 'o9', title: 'Archive Vol. III', description: 'Archived - Unmixed', tier: 'archived' as ItemTier },
  { id: 'o10', title: 'Session X-01', description: 'Experimental - Unreleased draft', tier: 'experimental' as ItemTier },
  { id: 'o11', title: 'Session X-02', description: 'Experimental - Internal', tier: 'experimental' as ItemTier },
  { id: 'o12', title: 'Session X-03', description: 'Experimental - Do not distribute', tier: 'experimental' as ItemTier },
];
 
function getGap(value: unknown): GalleryGap {
  if (value === 'xs' || value === 'sm' || value === 'lg' || value === 'xl') return value;
  return 'md';
}
 
function getInt(value: unknown, min: number, max: number, fallback: number) {
  const numericValue = Number(value);
  if (Number.isNaN(numericValue)) return fallback;
  return Math.min(max, Math.max(min, Math.round(numericValue)));
}
 
function getAspectRatio(value: unknown): string {
  if (typeof value === 'string' && value.includes('/')) return value;
  return '4/3';
}
 
function getOrientation(value: unknown): 'vertical' | 'horizontal' {
  return value === 'horizontal' ? 'horizontal' : 'vertical';
}
 
function itemClassName(tier: ItemTier) {
  if (tier === 'archived') return 'opacity-60';
  if (tier === 'experimental') return 'opacity-35';
  return undefined;
}
 
export function renderPreview(props: Record<string, unknown>) {
  const orientation = getOrientation(props.orientation);
  const columns = getInt(props.columns, 1, 4, 2);
  const gap = getGap(props.gap);
  const ratio = getAspectRatio(props.aspectRatio);
  const count = getInt(props.itemCount, 4, 12, 4);
  const items = ORIENTATION_ITEMS.slice(0, count);
  const resolvedColumns: GalleryProps['columns'] = orientation === 'horizontal' ? 1 : columns;
 
  return (
    <Gallery columns={resolvedColumns} gap={gap} className="w-full">
      {items.map((item) => (
        <Gallery.Item
          key={item.id}
          orientation={orientation}
          className={itemClassName(item.tier)}
          aria-disabled={item.tier === 'experimental' ? true : undefined}
        >
          <Gallery.View aspectRatio={orientation === 'horizontal' ? '1/1' : ratio}>
            <Frame
              pathStroke={item.tier === 'experimental' ? 'dotted' : 'dashed'}
              style={FRAME_STYLE[item.tier]}
              className="w-full h-full"
            />
          </Gallery.View>
          <Gallery.Body>
            <span>{item.title}</span>
            <span>{item.description}</span>
          </Gallery.Body>
        </Gallery.Item>
      ))}
    </Gallery>
  );
}
 
export default function Example() {
  return renderPreview({ orientation: 'vertical', columns: 2, gap: 'sm', aspectRatio: '4/3', itemCount: 4 });
}
 

Span Layout

A featured item spans multiple columns and rows to create an editorial grid layout.

"use client";
 
import type { CSSProperties } from 'react';
import { Gallery, Frame } from 'ui-lab-components';
import type { GalleryProps } from 'ui-lab-components';
 
type ControlDef = {
  name: string;
  label: string;
  type: 'select' | 'toggle' | 'text' | 'stepper';
  options?: Array<{ label: string; value: string | number | boolean }>;
  defaultValue?: string | number | boolean;
  min?: number;
  max?: number;
  step?: number;
};
 
type GalleryGap = NonNullable<GalleryProps['gap']>;
type ItemTier = 'common' | 'archived' | 'experimental';
 
const FRAME_STYLE: Record<ItemTier, CSSProperties> = {
  common: {
    '--frame-fill': 'var(--background-900)',
    '--frame-stroke-color': 'var(--background-600)',
  } as CSSProperties,
  archived: {
    '--frame-fill': 'var(--background-950)',
    '--frame-stroke-color': 'var(--background-700)',
  } as CSSProperties,
  experimental: {
    '--frame-fill': 'var(--background-950)',
    '--frame-stroke-color': 'var(--background-800)',
  } as CSSProperties,
};
 
export const metadata = {
  title: 'Span Layout',
  description: 'A featured item spans multiple columns and rows to create an editorial grid layout.',
  access: 'free' as const,
};
 
export const controls: ControlDef[] = [
  {
    name: 'columns',
    label: 'Columns',
    type: 'stepper',
    defaultValue: 3,
    min: 2,
    max: 6,
    step: 1,
  },
  {
    name: 'gap',
    label: 'Gap Token',
    type: 'select',
    options: [
      { label: 'Extra Small', value: 'xs' },
      { label: 'Small', value: 'sm' },
      { label: 'Medium', value: 'md' },
      { label: 'Large', value: 'lg' },
    ],
    defaultValue: 'md',
  },
  {
    name: 'featuredColumnSpan',
    label: 'Featured Column Span',
    type: 'stepper',
    defaultValue: 2,
    min: 1,
    max: 4,
    step: 1,
  },
  {
    name: 'featuredRowSpan',
    label: 'Featured Row Span',
    type: 'stepper',
    defaultValue: 2,
    min: 1,
    max: 3,
    step: 1,
  },
  {
    name: 'featuredAspect',
    label: 'Featured Aspect Ratio',
    type: 'select',
    options: [
      { label: '4:3', value: '4/3' },
      { label: '16:9', value: '16/9' },
      { label: '21:9', value: '21/9' },
      { label: '1:1', value: '1/1' },
    ],
    defaultValue: '16/9',
  },
  {
    name: 'itemCount',
    label: 'Items',
    type: 'stepper',
    defaultValue: 5,
    min: 3,
    max: 12,
    step: 1,
  },
];
 
export const previewLayout = 'start' as const;
export const resizable = true;
 
const SPAN_ITEMS = [
  { id: 's1', title: "Editor's Pick", description: 'Featured collection - Spring 2024', tier: 'common' as ItemTier },
  { id: 's2', title: 'Series No. 1', description: 'Monochrome', tier: 'common' as ItemTier },
  { id: 's3', title: 'Series No. 2', description: 'Landscape', tier: 'common' as ItemTier },
  { id: 's4', title: 'Series No. 3', description: 'Portrait', tier: 'common' as ItemTier },
  { id: 's5', title: 'Series No. 4', description: 'Abstract', tier: 'common' as ItemTier },
  { id: 's6', title: 'Series No. 5', description: 'Documentary', tier: 'common' as ItemTier },
  { id: 's7', title: 'Hidden Vol. I', description: 'Archived - Unlisted', tier: 'archived' as ItemTier },
  { id: 's8', title: 'Hidden Vol. II', description: 'Archived - Private', tier: 'archived' as ItemTier },
  { id: 's9', title: 'Hidden Vol. III', description: 'Archived - Limited print', tier: 'archived' as ItemTier },
  { id: 's10', title: 'Vault A', description: 'Experimental - Access restricted', tier: 'experimental' as ItemTier },
  { id: 's11', title: 'Vault B', description: 'Experimental - Internal preview', tier: 'experimental' as ItemTier },
  { id: 's12', title: 'Vault C', description: 'Experimental - Embargoed', tier: 'experimental' as ItemTier },
];
 
function getGap(value: unknown): GalleryGap {
  if (value === 'xs' || value === 'sm' || value === 'lg' || value === 'xl') return value;
  return 'md';
}
 
function getInt(value: unknown, min: number, max: number, fallback: number) {
  const numericValue = Number(value);
  if (Number.isNaN(numericValue)) return fallback;
  return Math.min(max, Math.max(min, Math.round(numericValue)));
}
 
function getAspectRatio(value: unknown): string {
  if (typeof value === 'string' && value.includes('/')) return value;
  return '4/3';
}
 
function itemClassName(tier: ItemTier) {
  if (tier === 'archived') return 'opacity-60';
  if (tier === 'experimental') return 'opacity-35';
  return undefined;
}
 
export function renderPreview(props: Record<string, unknown>) {
  const columns = getInt(props.columns, 2, 6, 3);
  const gap = getGap(props.gap);
  const colSpan = Math.min(getInt(props.featuredColumnSpan, 1, 4, 2), columns);
  const rowSpan = getInt(props.featuredRowSpan, 1, 3, 2);
  const featuredRatio = getAspectRatio(props.featuredAspect);
  const count = getInt(props.itemCount, 3, 12, 5);
  const [featured, ...rest] = SPAN_ITEMS.slice(0, count);
 
  return (
    <Gallery columns={columns} gap={gap} className="w-full">
      <Gallery.Item columnSpan={colSpan} rowSpan={rowSpan}>
        <Gallery.View aspectRatio={featuredRatio}>
          <Frame pathStroke="dashed" style={FRAME_STYLE.common} className="w-full h-full" />
        </Gallery.View>
        <Gallery.Body>
          <span>{featured.title}</span>
          <span>{featured.description}</span>
        </Gallery.Body>
      </Gallery.Item>
      {rest.map((item) => (
        <Gallery.Item
          key={item.id}
          className={itemClassName(item.tier)}
          aria-disabled={item.tier === 'experimental' ? true : undefined}
        >
          <Gallery.View aspectRatio="4/3">
            <Frame
              pathStroke={item.tier === 'experimental' ? 'dotted' : 'dashed'}
              style={FRAME_STYLE[item.tier]}
              className="w-full h-full"
            />
          </Gallery.View>
          <Gallery.Body>
            <span>{item.title}</span>
            <span>{item.description}</span>
          </Gallery.Body>
        </Gallery.Item>
      ))}
    </Gallery>
  );
}
 
export default function Example() {
  return renderPreview({ columns: 3, gap: 'md', featuredColumnSpan: 2, featuredRowSpan: 2, featuredAspect: '16/9', itemCount: 5 });
}
 
UI Lab