npm package: @inkorange/pendu
Description: Organic gallery layout engine for React
Author: Chris West
Latest version: 0.3.0

<p align="center">
  <a href="https://pendu.chriswest.tech">
    <img src="https://raw.githubusercontent.com/inkorange/pendu/main/resources/pendu.png" alt="Pendu" width="400" />
  </a>
</p>

<p align="center">
  <strong>Organic gallery layouts for React.</strong><br/>
  Arrange images and custom content into beautiful, natural collages — no grid, no masonry, just art.
</p>

<p align="center">
  <a href="https://pendu.chriswest.tech">Website</a> &middot;
  <a href="https://pendu.chriswest.tech/examples">Examples</a> &middot;
  <a href="https://pendu.chriswest.tech/docs">Docs</a> &middot;
  <a href="https://pendu.chriswest.tech/playground">Playground</a>
</p>

<p align="center">
  <a href="https://www.npmjs.com/package/@inkorange/pendu"><img src="https://img.shields.io/npm/v/@inkorange/pendu" alt="npm version" /></a>
  <img src="https://img.shields.io/badge/gzipped-5.7%20KB-blue" alt="5.7 KB gzipped" />
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="zero dependencies" />
  <img src="https://img.shields.io/badge/coverage-99%25-brightgreen" alt="99% test coverage" />
  <a href="https://github.com/inkorange/pendu/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/@inkorange/pendu" alt="license" /></a>
</p>

<p align="center">
  <img src="https://raw.githubusercontent.com/inkorange/pendu/main/resources/pendu.gif" alt="Pendu demo — organic gallery layout with animated transitions" width="700" />
</p>

## Install

```bash
npm install @inkorange/pendu
```

## Quick Start

```tsx
import { Pendu } from '@inkorange/pendu';

function Gallery() {
  return (
    <Pendu gap={12} seed={42}>
      <Pendu.Image src="/sunset.jpg" width={1200} height={800} alt="Sunset" />
      <Pendu.Image src="/portrait.jpg" width={800} height={1200} alt="Portrait" />
      <Pendu.Image src="/cityscape.jpg" width={1600} height={1000} alt="City" />
    </Pendu>
  );
}
```

## Features

- **Organic layouts** — No rows, columns, or grids. Images arrange into natural, gallery-wall collages that fill your container.
- **Animated transitions** — FLIP animations smoothly move content when the gallery changes. Add, remove, or reorder — every transition feels intentional.
- **Container aware** — Automatically adapts to any container size — fixed, percentage, or viewport units. Content scales and reflows to fill the space.
- **Lazy loading** — Native browser lazy loading with a single prop. Images load on demand, keeping initial page load fast.
- **Size constraints** — Control frame sizing with `minItemWidth` and `maxItemWidth`. Keep portrait images visible and wide images in check.
- **Layout callbacks** — Subscribe to layout changes with `onLayoutChange` for lightboxes, tooltips, or custom overlays.
- **Tiny footprint** — 5.7 KB gzipped, zero runtime dependencies. Only 6 files installed, nothing beyond React as a peer dependency.
- **CSS variable theming** — Customize gap, radius, and background via `--pendu-*` custom properties. No prop drilling needed.
- **Deterministic seeds** — Same seed + same inputs = identical layout. Reproducible across renders, servers, and sessions.
- **TypeScript** — Full type safety with exported interfaces.
- **SSR ready** — Works with Next.js App Router and React Server Components.

## Dynamic Images

Pendu reacts to children changes automatically. Just update your array:

```tsx
import { useState } from 'react';
import { Pendu } from '@inkorange/pendu';

function PhotoManager({ photos }) {
  const [visible, setVisible] = useState(photos);

  const remove = (id: string) => {
    setVisible(prev => prev.filter(p => p.id !== id));
  };

  return (
    <Pendu gap={12}>
      {visible.map(photo => (
        <Pendu.Image
          key={photo.id}
          src={photo.src}
          width={photo.width}
          height={photo.height}
          alt={photo.alt}
        />
      ))}
    </Pendu>
  );
}
```

## Custom Content with Pendu.Item

Mix images with any React component. Use `Pendu.Item` for videos, cards, or custom content:

```tsx
<Pendu gap={12}>
  <Pendu.Image src="/photo.jpg" width={1200} height={800} alt="Photo" />

  <Pendu.Item width={400} height={300}>
    <video src="/clip.mp4" autoPlay muted loop />
  </Pendu.Item>

  <Pendu.Item width={400} height={400}>
    <div className="promo-card">
      <h3>New Collection</h3>
      <p>Explore the latest additions</p>
    </div>
  </Pendu.Item>
</Pendu>
```

## CSS Variable Theming

Style without props — set CSS custom properties on the gallery or any ancestor:

```css
.my-gallery {
  --pendu-gap: 16px;
  --pendu-frame-radius: 8px;
  --pendu-bg: #1a1a1a;
  --pendu-transition-duration: 500ms;
  --pendu-transition-easing: cubic-bezier(0.25, 0.1, 0.25, 1);
  --pendu-skeleton-bg: #333;
}
```

## API Reference

### `<Pendu>` Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `gap` | `number` | `16` | Space between frames in pixels |
| `seed` | `number` | random | Random seed for deterministic layouts |
| `padding` | `number` | `10` | Inner padding of the gallery |
| `animate` | `boolean` | `true` | Enable FLIP animations |
| `animationDuration` | `number` | `300` | Animation duration in ms |
| `lazy` | `boolean` | `false` | Enable native lazy loading on images |
| `minItemWidth` | `number` | — | Minimum frame width in pixels |
| `maxItemWidth` | `number` | — | Maximum frame width in pixels |
| `onLayoutChange` | `(result: LayoutResult) => void` | — | Callback fired after layout computation |
| `className` | `string` | — | CSS class on the root element |

### `<Pendu.Image>` Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `src` | `string` | required | Image source URL |
| `width` | `number` | required | Original width (for aspect ratio) |
| `height` | `number` | required | Original height (for aspect ratio) |
| `alt` | `string` | `""` | Alt text for accessibility |
| `loading` | `'lazy' \| 'eager'` | — | Native image loading strategy |

### `<Pendu.Item>` Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `width` | `number` | required | Desired width (for aspect ratio) |
| `height` | `number` | required | Desired height (for aspect ratio) |
| `children` | `ReactNode` | required | Content to render inside the frame |

## Lazy Loading

Enable native browser lazy loading across all images:

```tsx
<Pendu lazy gap={12}>
  <Pendu.Image src="/photo1.jpg" width={1200} height={800} alt="Photo 1" />
  <Pendu.Image src="/photo2.jpg" width={800} height={1200} alt="Photo 2" />
</Pendu>
```

Or control per-image with the `loading` prop on `Pendu.Image`:

```tsx
<Pendu gap={12}>
  <Pendu.Image src="/hero.jpg" width={1200} height={800} alt="Hero" loading="eager" />
  <Pendu.Image src="/below-fold.jpg" width={800} height={1200} alt="Below" loading="lazy" />
</Pendu>
```

## Size Constraints

Control how large or small frames can be with `minItemWidth` and `maxItemWidth`:

```tsx
<Pendu gap={12} minItemWidth={120} maxItemWidth={400}>
  <Pendu.Image src="/portrait.jpg" width={800} height={1200} alt="Portrait" />
  <Pendu.Image src="/panorama.jpg" width={2400} height={600} alt="Panorama" />
</Pendu>
```

## Layout Callback

React to layout changes — useful for building lightboxes, tooltips, or analytics:

```tsx
<Pendu gap={12} onLayoutChange={(result) => {
  console.log(`Placed ${result.stats.placed} frames`);
  console.log('Bounds:', result.bounds.width, 'x', result.bounds.height);
}}>
  <Pendu.Image src="/photo.jpg" width={1200} height={800} alt="Photo" />
</Pendu>
```

## Container Behavior

- **Dynamic height (default):** Gallery grows vertically to fit all content. Container only needs a width.
- **Fixed height:** When the parent has a fixed height (px, vh, %), the gallery scales to fit both dimensions.
- **Responsive:** Pendu observes container resizes and re-layouts automatically.

## Changelog

### v0.3.0
- **`lazy` prop** — Enable native lazy loading on all gallery images with a single prop
- **`loading` prop on `Pendu.Image`** — Per-image control over lazy/eager loading
- **`minItemWidth` / `maxItemWidth` props** — Constrain frame sizes while preserving aspect ratios
- **`onLayoutChange` callback** — Subscribe to layout computation results for building features on top of Pendu

### v0.2.0
- Social card support with `Pendu.Item` for custom content
- Photo crossfade and zoom transition examples
- Improved layout expansion for better space filling
- Portrait image sizing improvements

### v0.1.0
- Initial release — organic center-out gallery layout engine
- FLIP animations for add/remove/reorder transitions
- CSS variable theming
- Container-aware sizing with ResizeObserver
- Deterministic seeded layouts

## License

MIT