pinch-grid
v0.1.0
Published
Pinch-to-zoom grid density switching for mobile. Zero dependencies.
Downloads
225
Maintainers
Readme
pinch-grid
A tiny library for pinch-to-zoom grid density switching on mobile. Think iOS Photos, but for any product grid on the web.
Users spread two fingers to zoom into single-column view (bigger images, more detail). Pinch to zoom back out to multi-column. No dependencies, no framework lock-in, runs on raw touch events and CSS Grid.
~1.5KB gzipped. Zero dependencies. Works everywhere.
Live Demo (coming soon)
Why this exists
Mobile e-commerce is a tradeoff between density and detail. Two-column grids let shoppers scan more products. Single-column grids show bigger images and room for ratings, descriptions, quick-add buttons. Most stores pick one layout and stick with it.
This library lets the user decide. The interaction is intuitive because people already pinch-zoom on product images. Extending that gesture to the grid itself is a small leap that feels immediately obvious once you try it.
Install
npm install pinch-gridOr grab the files directly. The entire library is two files: one JS module, one CSS file.
<link rel="stylesheet" href="pinch-grid.css">
<script type="module">
import { init } from './pinch-grid.js';
init('.product-grid');
</script>Quick start
Add data-pinch-grid to your grid container. Tag any detail content you want hidden in compact mode with data-pg-detail.
<div class="product-grid" data-pinch-grid>
<div class="product-card">
<img src="product.jpg" alt="Product name">
<h3>Product Name</h3>
<p class="price">$49.99</p>
<!-- This block only shows in single-column mode -->
<div data-pg-detail>
<p class="rating">4.8 stars (127 reviews)</p>
<p class="description">Lightweight, durable, built to last.</p>
<button>Add to Cart</button>
</div>
</div>
<!-- more cards -->
</div>import { init } from 'pinch-grid';
const grid = init('.product-grid', {
columns: [1, 2],
default: 2,
persist: true
});That's it. Pinch gestures work immediately on touch devices. Cards animate between layouts using FLIP transforms (GPU-composited, no layout thrashing). Scroll position stays anchored to whatever card the user was looking at.
How it works
The library does four things and tries to do them well:
1. Gesture detection. Native touchstart/touchmove listeners track two-finger distance. When the pinch ratio crosses a threshold (default 0.30), the column count switches. No gesture libraries, no pointer event abstraction layers. Just distance math on raw touches.
2. FLIP animation. Before switching columns, we snapshot every card's bounding rect. After the CSS Grid reflows to the new column count, we calculate the delta and animate each card from its old position to its new one using Element.animate() with transform: translate() scale(). Transforms stay on the compositor thread, so the animation runs at 60fps without triggering layout recalculations. A subtle stagger (15ms per card, capped at 60ms) gives it a cascading feel.
3. Content adaptation. Elements with data-pg-detail expand and collapse using grid-template-rows: 0fr to 1fr, the modern CSS technique for animating to/from zero height without the old max-height hack. In compact mode, detail content collapses smoothly. In single-column mode, it expands.
4. Scroll anchoring. Changing column count shifts the page layout. Without correction, the user loses their place. We find the card closest to the upper third of the viewport, record its offset, switch columns, then correct scroll position so that card stays visually anchored.
API
init(selector, options?)
Returns a PinchGrid instance.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| columns | number[] | [1, 2] | Available column counts, ordered ascending |
| default | number | Last value in columns | Starting column count |
| persist | boolean | true | Save preference to localStorage |
| onSwitch | (cols: number) => void | null | Callback when columns change |
Instance methods
grid.setColumns(1); // Switch to single column (with animation)
grid.getColumns(); // Returns current column count
grid.destroy(); // Remove listeners and clean upCSS custom properties
The library sets these on the grid container. Override them for custom spacing:
[data-pinch-grid][data-pg-cols="1"] {
--pg-gap: 24px; /* default: 20px */
}
[data-pinch-grid][data-pg-cols="2"] {
--pg-gap: 8px; /* default: 12px */
}Data attributes
| Attribute | Element | Purpose |
|-----------|---------|---------|
| data-pinch-grid | Grid container | Identifies the grid (added automatically if missing) |
| data-pg-cols | Grid container | Current column count (set by JS, used by CSS) |
| data-pg-detail | Any child element | Content visible only in single-column mode |
Platform integration
pinch-grid is framework-agnostic. It takes a selector or DOM element and handles everything internally. Here are integration notes for specific platforms.
Shopify (Liquid)
Drop the JS/CSS into your theme assets. Initialize on the collection template's product grid container. The data-pg-detail attribute works on any element inside your product card snippet.
<!-- sections/collection-template.liquid -->
<div id="product-grid" data-pinch-grid>
{% for product in collection.products %}
{% render 'product-card', product: product %}
{% endfor %}
</div>
<script type="module">
import { init } from '{{ "pinch-grid.js" | asset_url }}';
init('#product-grid');
</script>BigCommerce (Stencil)
Initialize in the category page's JS module. Target the existing product grid container in your Cornerstone/Starter/Roots theme.
// assets/js/theme/category.js
import { init } from './pinch-grid';
export default class Category extends PageManager {
onReady() {
if ('ontouchstart' in window) {
init('.productGrid', {
columns: [1, 2],
default: 2,
persist: true
});
}
}
}React
Wrap init in a useEffect. The ref ensures we target the right container.
import { useEffect, useRef } from 'react';
import { init } from 'pinch-grid';
function ProductGrid({ products }) {
const gridRef = useRef(null);
useEffect(() => {
const pg = init(gridRef.current, { columns: [1, 2] });
return () => pg?.destroy();
}, []);
return (
<div ref={gridRef} data-pinch-grid>
{products.map(p => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}Vanilla HTML
Works out of the box. Include the CSS, import the module, call init. No build step required.
Configuration
Adding a toggle button
The library handles gestures, but you probably want a visible toggle for discoverability. Wire it up with setColumns:
const grid = init('.product-grid', {
columns: [1, 2],
onSwitch(cols) {
document.querySelectorAll('.grid-toggle').forEach(btn => {
btn.classList.toggle('active', parseInt(btn.dataset.cols) === cols);
});
}
});
document.querySelectorAll('.grid-toggle').forEach(btn => {
btn.addEventListener('click', () => {
grid.setColumns(parseInt(btn.dataset.cols));
});
});Three-column support (tablets)
Pass three values in the columns array. Pinch gestures cycle through them in order.
init('.product-grid', {
columns: [1, 2, 3],
default: 2
});You'll need to add a CSS rule for the third state:
[data-pinch-grid][data-pg-cols="3"] {
--pg-cols: 3;
--pg-gap: 8px;
}Disabling persistence
If you don't want the column preference saved across page loads:
init('.product-grid', { persist: false });Performance
The library is designed to have zero measurable impact on page load and interaction metrics.
Bundle size: ~1.5KB gzipped (JS + CSS combined). No tree-shaking needed because there's nothing to shake.
Runtime cost: Two touch event listeners on the grid container (passive where possible, active only during two-finger gestures to prevent default zoom). No resize observers, no intersection observers, no mutation observers, no polling.
Animation: FLIP transforms run entirely on the compositor thread. No forced synchronous layouts during animation. will-change is set only during active transitions and removed immediately after.
Detail content: data-pg-detail visibility is pure CSS (grid-template-rows transition). No JS DOM manipulation for show/hide. No display: none toggling that would cause layout shifts.
Scroll anchoring: One getBoundingClientRect read per card before the switch, one scrollBy correction after. Runs once per gesture, not continuously.
What we intentionally don't do
No ResizeObserver for responsive breakpoints. If you need the grid to auto-switch at certain widths, handle that in your own CSS and call setColumns() from a media query listener. Bundling responsive logic into the library would bloat it and conflict with whatever responsive system your theme already uses.
No virtual scrolling or infinite scroll integration. pinch-grid handles layout density. Pair it with whatever pagination or infinite scroll solution you already have.
Project structure
pinch-grid/
src/
pinch-grid.js # ES module, library core
pinch-grid.css # Required styles
dist/
pinch-grid.min.js # Minified build
pinch-grid.min.css # Minified styles
pinch-grid.iife.js # Script-tag-friendly build
demo/
index.html # Interactive demo page
test/
pinch-grid.test.js # Unit tests
package.json
tsconfig.json # Type declarations (source is JS, types via JSDoc)
LICENSE
README.mdBuild
npm install
npm run build # Produces dist/
npm run dev # Serves demo with live reload
npm test # Runs test suiteThe build uses esbuild for bundling (fast, no config bloat). Output targets:
dist/pinch-grid.min.js(ESM, tree-shakeable)dist/pinch-grid.iife.js(globalPinchGridfor script tags)dist/pinch-grid.min.css
Browser support
Any browser that supports CSS Grid, Element.animate(), and touch events. In practice: Safari 13.1+, Chrome 67+, Firefox 63+, Edge 79+. That covers every mobile browser that matters.
Desktop browsers work fine with the toggle button API. Pinch gestures require a trackpad that supports multi-touch (most modern laptops do).
Accessibility
Column preference is persisted so users don't have to re-set it on every page. The hint pill that appears during pinch gestures is marked aria-hidden since it's purely visual feedback for an interaction that's already happening.
If you add toggle buttons (recommended), make sure they have appropriate aria-pressed or aria-current states. The library doesn't generate any UI elements except the transient hint overlay.
data-pg-detail content uses CSS visibility, not display: none, so screen readers can still access all product information regardless of the visual column count. Hidden detail content is collapsed to zero height but remains in the accessibility tree.
Roadmap
- [x] TypeScript type declarations (
.d.ts) - [ ] React wrapper hook (
usePinchGrid) - [ ] Configurable animation duration and easing via options
- [ ]
columns: 'auto'mode that reads breakpoints from CSS - [ ] Three-column grid demo
- [ ] Haptic feedback option via
navigator.vibrate() - [ ] Published demo site
Contributing
Issues and PRs welcome. If you're integrating pinch-grid into a platform theme and run into edge cases, I'd like to hear about it.
Before submitting a PR:
- Run
npm testand make sure everything passes - Keep the library under 2KB gzipped. If a feature would push it over, it should be a separate module or plugin
- No new dependencies. The zero-dep constraint is a feature, not a limitation
License
MIT
