@inkorange/pendu
v0.3.0
Published
Organic gallery layout engine for React
Readme
Install
npm install @inkorange/penduQuick Start
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
minItemWidthandmaxItemWidth. Keep portrait images visible and wide images in check. - Layout callbacks — Subscribe to layout changes with
onLayoutChangefor 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:
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:
<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:
.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:
<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:
<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:
<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:
<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
lazyprop — Enable native lazy loading on all gallery images with a single proploadingprop onPendu.Image— Per-image control over lazy/eager loadingminItemWidth/maxItemWidthprops — Constrain frame sizes while preserving aspect ratiosonLayoutChangecallback — Subscribe to layout computation results for building features on top of Pendu
v0.2.0
- Social card support with
Pendu.Itemfor 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
