Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,117 @@
# Rectpackr Layout

A web component that creates layouts by treating your HTML elements as rectangles and packing them using a best-fit 2D strip-packing algorithm.

## Why a Packing Algorithm for Web Layouts?

Web browsers naturally manage elements as rectangles. `rectpackr-layout` leverages this by applying a best-fit strip-packing algorithm — the same approach used in industrial optimization problems — to web layout creation.

## Intelligent Layouts Through Automated Measurement

The algorithm intelligently works with whatever dimensional information is available:

### How It Works:

- **Automatically measures** element dimensions through browser APIs
- **Uses width as the primary constraint** for predictable flow
- **Adapts to any height** — whether fixed, aspect-ratio based, or content-determined
- **Handles mixed content seamlessly** without manual configuration

### You Can:

- Set explicit widths for pixel-perfect control
- Use percentage-based or responsive widths
- Let elements determine their own natural sizes
- Mix and match approaches within the same layout

### What This Enables:

- **Truly flexible layouts** that work with your existing CSS approach
- **Zero-configuration setups** for rapid prototyping
- **Production-ready precision** when you need exact control
- **Best of both worlds** — automation when you want it, control when you need it

## Installation

### Install the package via your preferred package manager:

#### npm

```bash
npm install rectpackr-layout
```

#### yarn

```bash
yarn add rectpackr-layout
```

#### pnpm

```bash
pnpm install rectpackr-layout
```

Then import it in your JavaScript:

```javascript
// In your main.js or component file
import 'rectpackr-layout';
```

Or directly in your HTML:

```html
<script type="module">
import 'rectpackr-layout';
</script>
```

## API Reference

### Attributes

**`positioning`**

Defines the CSS method used to position items.

- `transform` (_Default_): Uses `transform: translate(x, y)`
- `offset`: Uses CSS offset properties (`top`/`bottom` and `left`/`right`)

> **Performance Note:** The default `transform` value typically offers better performance through hardware acceleration. Use `offset` only when child elements already use `transform` for other purposes (animation etc.).

**`x-direction`**

Controls the horizontal packing direction.

- `ltr` (_Default_): Left-to-right packing
- `rtl`: Right-to-left packing

**`y-direction`**

Controls the vertical packing direction.

- `ttb` (_Default_): Top-to-bottom packing
- `btt`: Bottom-to-top packing

### A Note on Visual Order & Accessibility

The `x-direction` and `y-direction` attributes control visual placement, which may differ from DOM order.

- **DOM Order is Preserved:** The library never changes the underlying HTML structure, ensuring correct tab order and screen reader navigation
- **Visual Order is Optimized:** The algorithm places items for spatial efficiency, which may not match linear DOM order

**Best Practice:** Ensure your HTML source reflects the logical reading order.

## Browser Support

Modern browsers with Web Components support.

## Issues and Support

If you encounter any issues or have questions, please [open an issue](https://github.com/styiannis/rectpackr-layout/issues).

## License

This project is licensed under the [MIT License](https://github.com/styiannis/rectpackr-layout?tab=MIT-1-ov-file#readme).
24 changes: 20 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
{
"name": "rectpackr-layout",
"version": "0.0.2",
"description": "",
"keywords": [],
"version": "0.8.0",
"description": "A web component that creates layouts by packing HTML elements as rectangles using a best-fit 2D strip-packing algorithm. Automatically measures dimensions and handles mixed content.",
"keywords": [
"best-fit",
"custom-element",
"custom-elements",
"dom",
"layout",
"masonry",
"masonry-layout",
"responsive",
"responsive-layout",
"rectangle-packer",
"strip-packing",
"web-component",
"web-components"
],
"author": "Yiannis Stergiou <hello@styiannis.dev>",
"license": "MIT",
"repository": {
Expand All @@ -15,6 +29,8 @@
"source": "src/index.ts",
"main": "dist/cjs/index.js",
"module": "dist/es/index.js",
"unpkg": "./dist/umd/index.js",
"jsdelivr": "./dist/umd/index.js",
"types": "dist/@types/index.d.ts",
"files": [
"CHANGELOG.md",
Expand Down Expand Up @@ -68,7 +84,7 @@
".": {
"import": "./dist/es/index.js",
"require": "./dist/cjs/index.js",
"default": "./dist/es/index.js"
"default": "./dist/umd/index.js"
}
}
}
87 changes: 87 additions & 0 deletions src/RectpackrLayout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { IRectpackrConfig, IRectpackr, rectpackr } from './core';

/* ------------------------------------------------------------------------- */
/* -------------------------- // Helper functions -------------------------- */
/* ------------------------------------------------------------------------- */

const parseConfig = (config: {
positioning: string | undefined;
'x-direction': string | undefined;
'y-direction': string | undefined;
}): IRectpackrConfig => ({
positioning:
(config.positioning?.trim() ?? '') === 'offset' ? 'offset' : 'transform',
'x-direction':
(config['x-direction']?.trim() ?? '') === 'rtl' ? 'rtl' : 'ltr',
'y-direction':
(config['y-direction']?.trim() ?? '') === 'btt' ? 'btt' : 'ttb',
});

const getStyleTextContent = (config: IRectpackrConfig) => {
const insetValue = {
ltr: { ttb: '0 auto auto 0', btt: 'auto auto 0 0' },
rtl: { ttb: '0 0 auto auto', btt: 'auto 0 0 auto' },
}[config['x-direction']][config['y-direction']];

return `
slot{ box-sizing: border-box; position:relative; display:block; width:100% }
::slotted(:not([slot])){ position:absolute; inset:${insetValue} }`;
};

/* ------------------------------------------------------------------------- */
/* -------------------------- Helper functions // -------------------------- */
/* ------------------------------------------------------------------------- */

export class RectpackrLayout extends HTMLElement {
#obj: IRectpackr | undefined = undefined;

static get observedAttributes() {
return ['positioning', 'x-direction', 'y-direction'];
}

#clear() {
if (this.#obj) {
rectpackr.clear(this.#obj);
this.#obj = undefined;
}
}

#render() {
const config = parseConfig({
positioning: this.getAttribute('positioning') ?? undefined,
'x-direction': this.getAttribute('x-direction') ?? undefined,
'y-direction': this.getAttribute('y-direction') ?? undefined,
});

if (this.shadowRoot) {
const slot = this.shadowRoot.querySelector('slot')!;
const style = this.shadowRoot.querySelector('style')!;
style.textContent = getStyleTextContent(config);
this.#obj = rectpackr.create(slot, this, config);
} else {
const shadowRoot = this.attachShadow({ mode: 'open' });
const slot = document.createElement('slot');
const style = document.createElement('style');
style.textContent = getStyleTextContent(config);
shadowRoot.appendChild(style);
shadowRoot.appendChild(slot);
this.#obj = rectpackr.create(slot, this, config);
}
}

attributeChangedCallback() {
if (!this.shadowRoot) {
return;
}
this.#clear();
this.#render();
}

connectedCallback() {
this.#render();
}

disconnectedCallback() {
this.#clear();
}
}
2 changes: 2 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * as rectpackr from './rectpackr';
export * from './types';
49 changes: 49 additions & 0 deletions src/core/rectpackr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { BestFitStripPack } from 'best-fit-strip-pack';
import { IRectpackr } from './types';
import {
onChildResize,
onChildrenContainerMutation,
onContainerResize,
resetStyle,
startObserving,
stopObserving,
} from './util';

export function create<R extends IRectpackr>(
container: R['container'],
childrenContainer: R['childrenContainer'],
config: R['config']
) {
const childrenContainerMutation = new MutationObserver(() =>
onChildrenContainerMutation(instance)
);

const childrenResize = new ResizeObserver((entries) =>
onChildResize(instance, entries)
);

const containerResize = new ResizeObserver(() => onContainerResize(instance));

const stripPack = new BestFitStripPack(
Math.max(1, parseFloat(getComputedStyle(container).width))
);

const instance = {
config,
container,
children: [] as R['children'],
childrenContainer,
observers: { childrenContainerMutation, childrenResize, containerResize },
stripPack,
} as R;

startObserving(instance);

return instance;
}

export function clear<R extends IRectpackr>(instance: R) {
stopObserving(instance);
resetStyle(instance);
instance.stripPack.reset();
}
26 changes: 26 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { BestFitStripPack } from 'best-fit-strip-pack';

export type IRectpackrChildElement = HTMLElement | SVGElement;

export interface IRectpackrConfig {
positioning: 'offset' | 'transform';
'x-direction': 'ltr' | 'rtl';
'y-direction': 'ttb' | 'btt';
}

export interface IRectpackr {
config: IRectpackrConfig;
container: HTMLElement;
children: {
element: IRectpackrChildElement;
height: number;
width: number;
}[];
childrenContainer: HTMLElement;
observers: {
childrenContainerMutation: MutationObserver;
childrenResize: ResizeObserver;
containerResize: ResizeObserver;
};
stripPack: BestFitStripPack;
}
Loading
Loading