A modern, responsive grid library for video meeting layouts with smooth Motion animations.
Works with Vanilla JS, React, and Vue.
Demos · Features · Packages · Installation · Quick Start · Layout Modes · API Reference · License
| Feature | Description |
|---|---|
| 2 Layout Modes | Gallery (with optional Pin mode), Spotlight |
| Pin/Focus Support | Pin any participant to become the main view |
| Spring Animations | Smooth Motion (Framer Motion / Motion One) transitions |
| Pagination | Split participants across pages with navigation |
| Max Visible + "+N More" | Limit visible items and show overflow indicator |
| Flexible Aspect Ratios | Per-item ratios (phone 9:16, desktop 16:9) |
| Force Aspect Ratio | Force grid items to strictly adhere to their ratios |
| Floating PiP | Draggable Picture-in-Picture with corner snapping |
| Pin Only Mode | Mobile/tablet pin view with separate pagination |
| Grid Overlay | Full-grid overlay for screen sharing, whiteboard, etc. |
| Responsive | Adapts to container size with justified packing |
| Framework Support | Vanilla JS, React 18+, Vue 3 |
| TypeScript | Full type definitions |
| Tree-shakeable | Import only what you need |
| Package | Description | Size |
|---|---|---|
@thangdevalone/meeting-grid-layout-core |
Grid math only (Vanilla JS/TS) | ~3KB |
@thangdevalone/meeting-grid-layout-react |
React components + Motion | ~8KB |
@thangdevalone/meeting-grid-layout-vue |
Vue 3 components + Motion | ~8KB |
React and Vue packages re-export everything from core — no need to install core separately.
# Core only (Vanilla JavaScript/TypeScript)
npm install @thangdevalone/meeting-grid-layout-core
# React 18+
npm install @thangdevalone/meeting-grid-layout-react
# Vue 3
npm install @thangdevalone/meeting-grid-layout-vueimport { GridContainer, GridItem } from '@thangdevalone/meeting-grid-layout-react'
function MeetingGrid({ participants }) {
return (
<GridContainer aspectRatio="16:9" gap={8} layoutMode="gallery" count={participants.length}>
{participants.map((p, index) => (
<GridItem key={p.id} index={index}>
<VideoTile participant={p} />
</GridItem>
))}
</GridContainer>
)
}<script setup>
import { GridContainer, GridItem } from '@thangdevalone/meeting-grid-layout-vue'
const participants = ref([...])
</script>
<template>
<GridContainer aspect-ratio="16:9" :gap="8" :count="participants.length" layout-mode="gallery">
<GridItem v-for="(p, index) in participants" :key="p.id" :index="index">
<VideoTile :participant="p" />
</GridItem>
</GridContainer>
</template>import { createMeetGrid } from '@thangdevalone/meeting-grid-layout-core'
const grid = createMeetGrid({
dimensions: { width: 800, height: 600 },
count: 6,
aspectRatio: '16:9',
gap: 8,
layoutMode: 'gallery',
})
for (let i = 0; i < 6; i++) {
const { top, left } = grid.getPosition(i)
const { width, height } = grid.getItemDimensions(i)
element.style.cssText = `
position: absolute;
top: ${top}px;
left: ${left}px;
width: ${width}px;
height: ${height}px;
`
}| Mode | Description |
|---|---|
gallery |
Flexible grid filling all space. Use pinnedIndex for pin. |
spotlight |
Single participant fills the entire container. |
When pinnedIndex is set, the layout splits into a Focus Area (pinned item) and an Others Area (thumbnails):
<GridContainer
layoutMode="gallery"
pinnedIndex={0} // Pinned participant
othersPosition="right" // Others on the right
count={participants.length}
>othersPosition |
Description |
|---|---|
right |
Thumbnails on the right (default) |
left |
Thumbnails on the left |
top |
Thumbnails on top (horizontal strip) |
bottom |
Thumbnails on bottom (speaker-like layout) |
On mobile/tablet devices (container width ≤ 1024px), pinOnly provides a focused experience:
- Page 0: Only the pinned participant is shown full-screen
- Page 1+: Other participants are shown in a gallery grid (without pin)
On desktop (width > 1024px), the layout behaves as a normal sidebar.
// React
<GridContainer
layoutMode="gallery"
pinnedIndex={0}
maxVisible={4}
currentVisiblePage={currentPage}
pinOnly={true}
><!-- Vue -->
<GridContainer
layout-mode="gallery"
:pinned-index="0"
:max-visible="4"
:current-visible-page="currentPage"
:pin-only="true"
>Note:
pinOnlyrequires pagination (maxVisible > 0) to work. The total pages = 1 (pin page) + ceil(others / maxVisible).
Split participants across multiple pages:
<GridContainer
count={participants.length}
maxItemsPerPage={9}
currentPage={currentPage}
>For pin mode, use maxVisible and currentVisiblePage to paginate the "others" area:
<GridContainer
layoutMode="gallery"
pinnedIndex={0}
maxVisible={4}
currentVisiblePage={othersPage}
>Limit visible items and show an overflow indicator:
<GridContainer maxVisible={4} count={12}>
{participants.map((p, index) => (
<GridItem key={p.id} index={index}>
{({ isLastVisibleOther, hiddenCount }) => (
<>
{isLastVisibleOther && hiddenCount > 0 ? (
<div className="more-indicator">+{hiddenCount} more</div>
) : (
<VideoTile participant={p} />
)}
</>
)}
</GridItem>
))}
</GridContainer>Support different aspect ratios per participant (e.g., mobile portrait vs desktop landscape):
const itemAspectRatios = [
"16:9", // Desktop landscape
"9:16", // Mobile portrait
undefined, // Use global aspectRatio
]
<GridContainer
aspectRatio="16:9"
itemAspectRatios={itemAspectRatios}
>| Value | Description |
|---|---|
"16:9" |
Fixed landscape ratio |
"9:16" |
Portrait video (mobile) |
"4:3" |
Classic tablet ratio |
"auto" |
Stretch to fill the cell (default when not specified) |
undefined |
Use global aspectRatio |
By default, the GridItem wrappers stretch to fill the grid cells as much as possible, and you use contentDimensions to size your inner video elements correctly.
If you prefer the GridItem wrappers themselves to strictly adhere to the aspect ratio (e.g. for borders, backgrounds, or pure CSS layouts), set forceAspectRatio={true} on the GridContainer.
This forces the GridItem wrappers to shrink to the exact aspect ratio size and perfectly centers them inside their cells. (Floating PiP items always strictly maintain their aspect ratio natively).
<GridContainer
forceAspectRatio={true}
aspectRatio="16:9"
>When participants have mixed aspect ratios (e.g., some on phones with 9:16, others on desktops with 16:9), the grid uses an Area-Optimized Row Search algorithm to find the layout that maximizes space utilization while preserving correct aspect ratios.
A naive approach packs items row-by-row until a row is "full", then starts a new row. This often creates imbalanced layouts — for example, 10 items could end up as [4, 5, 1], leaving the last row with a single lonely item and lots of wasted space.
A simple height-diff approach (|totalH − containerH|) also fails with highly mixed ratios — it tends to pick single-row layouts that leave large vertical gaps (e.g., 3×4:3 + 2×9:16 all in one row).
Our algorithm avoids both issues by scoring each candidate layout on actual area utilization, weighted by how well it fills the container.
flowchart TD
A["Start: N items with mixed aspect ratios"] --> B["For each item, compute its w/h ratio\ne.g. 16:9 → 1.778, 9:16 → 0.5625"]
B --> C["Set search range:\nnumRows = 1, 2, 3, ...\nup to min(N, ⌈√N × 2.5⌉)"]
C --> D["For each candidate numRows"]
D --> E["Distribute N items evenly into numRows rows\n(each row gets ⌊N/R⌋ or ⌈N/R⌉ items)"]
E --> F["For each row, compute natural height:\nrowH = (containerWidth − gaps) / Σ(w/h ratios in row)"]
F --> G["totalH = Σ(all rowH) + gaps between rows"]
G --> H["scale = min(1.0, containerH / totalH)\nfillRatio = min(1.0, totalH × scale / containerH)"]
H --> I["Compute total item area after scaling\nscaledArea = naturalArea × scale²"]
I --> J["score = avgItemArea × fillRatio^1.5\n(penalizes layouts with large empty gaps)"]
J --> K{"Is score > current best?"}
K -- Yes --> L["Save this as bestRowCount"]
K -- No --> D
L --> D
D -- "All candidates tried" --> M["Build final layout with bestRowCount"]
M --> N["globalScale = min(1.0, containerH / totalH)\nApply same scale to ALL items"]
N --> O["Center items horizontally & vertically\nAspect ratios perfectly preserved ✓"]
style A fill:#6366f1,color:#fff
style L fill:#22c55e,color:#fff
style O fill:#6366f1,color:#fff
-
Compute w/h ratios — For each item, convert its aspect ratio string to a numeric width/height ratio:
16:9→1.778(wide landscape)9:16→0.5625(tall portrait)4:3→1.333,1:1→1.0
-
Set the search range — Try every row count from
1up tomin(N, ⌈√N × 2.5⌉). Skip any row count where⌊N/numRows⌋ = 0(would leave empty rows). -
Even distribution — For each candidate
numRows, items are split evenly:base = ⌊N / numRows⌋,extra = N % numRows- First
extrarows getbase + 1items, remaining rows getbaseitems - Example: 9 items into 2 rows →
[5, 4]; into 4 rows →[3, 2, 2, 2]
-
Compute natural height per row — If a row of items fills the full container width, how tall would it be?
rowHeight = (containerWidth − (itemsInRow − 1) × gap) / Σ(w/h ratios of items in row)Rows with tall/portrait items produce larger heights; rows with wide/landscape items produce smaller heights.
-
Area-based scoring — For each candidate:
- Compute
scale = min(1.0, containerH / totalH)— how much items must shrink to fit - Compute
fillRatio = min(1.0, totalH × scale / containerH)— how much vertical space is actually used - Compute
avgItemArea = totalItemArea × scale² / N— average item area after scaling - Score =
avgItemArea × fillRatio^1.5— this balances item size vs. space utilization. ThefillRatio^1.5exponent strongly penalizes layouts with large empty gaps (e.g., 1 row using only 60% of height gets a 0.46× penalty).
- Compute
-
Select winner — The row count with the highest score wins. This naturally balances:
- Fewer rows → bigger items but potential vertical waste (low fillRatio → penalty)
- More rows → better fill but heavy scaling (low avgItemArea)
-
Uniform scaling — Apply a single scale factor
globalScale = min(1.0, containerH / totalH)to all items equally. Because width and height scale by the same factor, every item's aspect ratio is perfectly preserved. -
Center & position — Each row is centered horizontally, and the entire grid is centered vertically in any remaining space.
The search range ⌈√N × 2.5⌉ is carefully chosen:
-
√Ngives the "square-ish" baseline. For N items in a regular grid,√Nrows ×√Ncolumns is the natural starting point. For example, 9 items → 3×3, 16 items → 4×4. -
Mixed aspect ratios may need more rows. When all items are tall/portrait (e.g., 9:16), fitting them side by side uses lots of column space — you might need significantly more rows than
√Nto utilize the container height well. -
The
2.5×multiplier provides enough headroom. It allows the search to go well beyond the square-root baseline to handle portrait-heavy mixes, without being wasteful:N (items) √N ⌈√N × 2.5⌉ Max rows tried 4 2.0 5 4 (capped at N) 9 3.0 8 8 16 4.0 10 10 25 5.0 13 13 50 7.07 18 18 -
min(N, ...)caps the maximum. You never need more rows than items. For small N (e.g., 4 items),⌈√4 × 2.5⌉ = 5is capped at4.
Container: 1024 × 460px
Items: 4:3, 9:16, 9:16, 4:3, 4:3
Search range: 1 to min(5, ⌈√5 × 2.5⌉) = min(5, 6) = 5
Area-based scoring:
┌───────────────────────────────────────────────────────────────────────────────┐
│ Rows=1: [5] scale=1.00 fillRatio=0.53 avgArea=61K score=23.6K ❌ │
│ Rows=2: [3, 2] scale=0.47 fillRatio=1.00 avgArea=42K score=42.0K ✅ │
│ Rows=3: [2,2,1] scale=0.27 fillRatio=1.00 avgArea=18K score=18.0K ❌ │
└───────────────────────────────────────────────────────────────────────────────┘
Winner: 2 rows [3, 2]
→ Items distributed across 2 rows, filling container height well
→ No large empty gaps above/below ✓
Old algorithm would pick Rows=1 (closest height diff), leaving ~47% vertical space empty.
| Metric | Value |
|---|---|
| Time complexity | O(N × √N) — N items × up to √N×2.5 candidates |
| Space | O(N) — only the winning distribution is allocated |
| Search phase | Zero allocations — pure arithmetic on ratio array |
| Typical speed | < 0.1ms for 50 participants |
Draggable floating item with corner snapping. Supports fixed or responsive sizing.
import { FloatingGridItem } from '@thangdevalone/meeting-grid-layout-react'
<GridContainer>
{/* Main grid items */}
{/* Fixed size (responsive automatically based on floatSize prop on GridContainer, or size prop here) */}
<FloatingGridItem size="large" aspectRatio="9:16" anchor="bottom-right">
<VideoTile participant={floatingParticipant} />
</FloatingGridItem>
</GridContainer>
{/* Auto-float in 2-person mode */}
<GridContainer count={2} floatBreakpoints={DEFAULT_FLOAT_BREAKPOINTS}>
{participants.map((p, i) => (
<GridItem key={p.id} index={i}><VideoTile participant={p} /></GridItem>
))}
</GridContainer>
{/* Choose which participant is the floating PiP */}
<GridContainer count={2} floatBreakpoints={DEFAULT_FLOAT_BREAKPOINTS} pipIndex={0}>
{participants.map((p, i) => (
<GridItem key={p.id} index={i}><VideoTile participant={p} /></GridItem>
))}
</GridContainer>Instead of complex breakpoints, you can easily set responsive sizes using floatSize / size:
floatSize |
Description |
|---|---|
'small' |
Compact PiP (80px on mobile → 160px on desktop) |
'medium' |
Standard PiP (110px on mobile → 210px on desktop) (Default) |
'large' |
Large PiP (140px on mobile → 260px on desktop) |
<FloatingGridItem size="large">...</FloatingGridItem>
// or
<GridContainer count={2} floatSize="large">...</GridContainer>Note: The layout will automatically adjust width and height based on the aspect ratio you provide to ensure the bounding box always fits the
floatSizescale properly.
In 2-person mode, pipIndex selects which participant becomes the floating PiP (the other fills the main area). Defaults to 1 (second participant).
pipIndex |
Main (full-screen) | Floating PiP |
|---|---|---|
0 |
Participant 1 | Participant 0 |
1 (default) |
Participant 0 | Participant 1 |
Set disableFloat={true} to disable the automatic Floating PiP in 2-person mode. When disabled, both participants are laid out in a standard gallery grid instead of one full-screen + one draggable PiP.
// Normal 2-person mode (PiP enabled by default)
<GridContainer count={2}>...</GridContainer>
// Disable PiP — show standard gallery grid with 2 tiles
<GridContainer count={2} disableFloat={true}>...</GridContainer>| Prop | Type | Default | Description |
|---|---|---|---|
visible |
boolean |
true |
Whether to show the overlay |
backgroundColor |
string |
'rgba(0,0,0,0.5)' |
Overlay background color |
children |
ReactNode |
- | Content inside the overlay |
git clone https://github.com/thangdevalone/meeting-grid-layout.git
cd meeting-grid-layout
pnpm install
pnpm build
# Run demos
pnpm dev
# React: http://localhost:5173
# Vue: http://localhost:5174Project structure:
meeting-grid-layout/
├── packages/
│ ├── core/ # Grid logic (framework-agnostic)
│ ├── react/ # React components + hooks
│ └── vue/ # Vue 3 components + composables
├── examples/
│ ├── react-demo/
│ └── vue-demo/
└── package.json
MIT © @thangdevalone
See LICENSE for details.