Gallery
Responsive grid for displaying images and media.
Mountain Landscape
Ocean Waves
Forest Path
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
Brand KitIdentity · Updated 2d ago
Icon SetUI Assets · 142 icons
Type ScaleTypography · 8 weights
Color TokensDesign System · v3.2
"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
Waveform Study12:34 · Electronic
Threshold8:02 · Ambient
Parallel Lines5:47 · Minimal
Resonance9:15 · Drone
"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
Editor's PickFeatured collection — Spring 2024
Series No. 1Monochrome
Series No. 2Landscape
Series No. 3Portrait
Series No. 4Abstract
"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).
Waveform Study12:34 - Electronic
Threshold8:02 - Ambient
Parallel Lines5:47 - Minimal
Resonance9:15 - Drone
"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.
Editor's PickFeatured collection - Spring 2024
Series No. 1Monochrome
Series No. 2Landscape
Series No. 3Portrait
Series No. 4Abstract
"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 });
}