Puck is the open-source visual editor for React, that you can embed in any application to create the next generation of page builders and no-code products. Give us a star on GitHub! ⭐️
Drag-and-drop page-builders are everywhere, but getting them to work seamlessly across different layouts—especially complex CSS Grid and Flexbox structures—has always been a challenge. With Puck 0.18, that just got a lot easier.
This update introduces a revamped drag-and-drop engine designed to work naturally across any CSS layout. Instead of forcing elements into rigid structures, you now have full control over the styles of DropZones and their children to fit your design needs, and let you do things like this:
With the new drag-and-drop engine, you can add any CSS layout however you want to your editor—but some patterns can make the process much smoother. That’s why in this post, we’ll go over some common patterns I’ve found useful for adding CSS Grid and Flex support to your editors, along with best practices to help you get the most out of Puck’s new engine.
Before we get started, I’ll assume you have a basic understanding of Puck and how it works. If you’re new here, no worries—you’re welcome to follow along! However, I’d recommend checking out the Getting Started guide first to familiarize yourself with the basics, as well as the 0.18 blog post to learn about the new features in this version.
Table of contents
- Grid patterns
- Flex patterns
- Best practices
🍱 Grid patterns
CSS Grids are built for structured, multi-column layouts with precise control over item placement. Unlike Flexbox, which distributes items dynamically based on content size, Grids let you define fixed or flexible tracks, making them a great fit for dashboards, forms, bento layouts, and any design where the number of rows and columns matters.
With Puck’s drag-and-drop engine, you can give users full control over CSS rules so they can build grids however they like. But in most cases, you'll want to guide them through the process to keep things intuitive for all levels of experience. Sometimes that means letting them define the number of columns and rows in a grid freely; other times, it makes more sense to provide a set of predefined grid designs from which to choose from.
With this in mind, in this section I’ve gathered four patterns I’ve found especially useful for adding grids to build intuitive and flexible page-building experiences across a variety of use cases.
Grid container pattern
The Grid container pattern is the foundation of all grid-based layouts in Puck. With this approach, the grid DropZone
itself defines the grid layout, so that all elements inside follow a predefined number of rows or columns. This is useful when you want to give control over the layout container and need a regular arrangement of elements in columns and rows, so it’s ideal for things like lists and content grids.
To set it up you need to follow these steps:
1. Define a new component in your Puck config for the grid
const config = {
components: {
// Define your Grid component for grid layouts
Grid: {
//... next steps here
},
},
};
2. Optionally, add fields to let users configure the grid—for example, you could add fields to set rules like the number of columns and rows
const config = {
components: {
Grid: {
// Add fields to configure the numbers of rows/columns in the grid
fields: {
columns: { type: "number" },
rows: { type: "number" },
},
},
},
};
3. Finally, render a DropZone
inside the Grid
component, applying a display: grid
CSS rule and any other desired layout rules or user-defined settings
const config = {
components: {
Grid: {
//... existing config
// Render the DropZone as a CSS grid
render: ({ columns, rows }) => {
return (
<DropZone
zone="grid-zone"
style={{
display: "grid",
gridTemplateColumns: `repeat(${columns || 1}, 1fr)`,
gridTemplateRows: `repeat(${rows || 1}, auto)`,
}}
/>
);
},
},
},
};
If you followed these steps, you’ll now be able to navigate to the editor in the browser, drag and drop a Grid
, set the number of columns and rows for it, and drop items inside to automatically snap them to the grid structure:
As you can see, this pattern works great for structured layouts, but if you need more flexibility—like controlling how individual items are placed within the grid, or allow the user to define custom multi-column layouts—you should check out the Grid container - Grid item pattern, which we’ll cover next.
Grid container - Grid item pattern
The Grid container - Grid item pattern builds on the Grid container pattern by giving you more flexibility in how items are arranged. Instead of enforcing a uniform layout where every item spans the same number of rows and columns, this approach lets users break grids into distinct sections with their own configurable drop zones.
The key to achieve this is adding a GridItem
component to your config. This component should render a DropZone
, provide fields to set how many rows and columns that DropZone
spans in the grid, and be restricted to use only inside Grid
components. With that in place you would allow users to:
- Drag and drop Grid Item zones inside a Grid
- Set how many columns and rows each zone should occupy
- Organize the layout visually by dragging and dropping zones around
- Drag and drop content into each zone
That’s why, with this pattern, you can define custom layouts directly in the editor. For example, you could create a layout with a navbar zone that spans all columns at the top, a table of contents that spans all rows on the left, and a main content section on the right.
To implement this pattern, follow these steps:
1. Add a Grid
component to your Puck config
const config = {
components: {
//... existing config
// Define a Grid component
Grid: {
render: () => {
return (
<DropZone
zone="grid-zone"
style={{
display: "grid",
gridTemplateColumns: `repeat(3, 1fr)`,
}}
/>
);
},
},
},
};
2. Assign the allow
prop on the Grid DropZone
to an array containing only the name of your GridItem
component. This will only allow GridItems
inside the grid
const config = {
components: {
Grid: {
render: () => {
return (
<DropZone
//... existing config
// Only allow grid items inside the grid
allow={["GridItem"]}
/>
);
},
},
},
};
3. Add the GridItem
component to your Puck config
const config = {
components: {
//... existing config
// Define your Grid Item component
GridItem: {
//... next steps here
},
},
};
4. Remove the default wrapping element Puck adds around draggable components for the GridItem
by enabling the inline
parameter. This will remove the Puck wrapper and allow you to style the grid items as direct children of the grid they are dropped in
const config = {
components: {
//... existing config
GridItem: {
// Remove the default element wrapper
inline: true,
},
},
};
5. Add fields to the GridItem
component to define the number of columns and rows each item should span
const config = {
components: {
GridItem: {
//... existing config
// Add fields for the number of columns/rows the item should span
fields: {
columns: { type: "number" },
rows: { type: "number" },
},
},
},
};
6. Render the DropZone
inside the GridItem
with the number of columns and rows it should span. Also, since you previously enabled the inline
parameter, you must pass the puck.dragRef
to the DropZone
to let Puck know it should be treated as a draggable element
const config = {
components: {
GridItem: {
//... existing config
// Render the DropZone with the number of columns/rows it should span
render: ({ columns, rows, puck }) => {
return (
<DropZone
ref={puck.dragRef}
zone="grid-item-zone"
style={{
gridColumn: `span ${columns || 1}`,
gridRow: `span ${rows || 1}`,
}}
/>
);
},
},
},
};
7. Finally, disallow GridItems
to be nested inside other GridItems
to provide a better experience while dragging elements around grids
const config = {
components: {
GridItem: {
render: ({ columns, rows, puck }) => {
return (
<DropZone
//... existing config
// Disallow GridItems inside
disallow={["GridItem"]}
/>
);
},
},
},
};
After following these steps, you should now be able to navigate to the browser, drag and drop a Grid
, add GridItems
inside, set the number of columns and rows each item should span, and drag any component inside these grid zones to automatically adjust it to fit the layout.
This pattern is great when you want to provide full control over layout structure, but if you just need a way for users to drop any component into a grid and still define how much space it should take up inside, the Grid container - Any item pattern is a better fit.
Grid container - Any item pattern
The Grid container - Any item pattern is a more flexible approach to working with grids, allowing any component to be placed inside while still letting users control how much space each item takes up. Instead of limiting grid items to a specific type, this pattern enables any component to define their own grid placement dynamically when dropped inside of a grid.
With this pattern, users can:
- Drop any component inside a grid
- Define how many rows and columns each item should span
- Keep components fully functional outside of grids by hiding the rows and columns fields
The key here is using dynamic fields to adjust component settings based on their parent. If a component is inside a Grid, it should show columns
and rows
fields to let users define its span. If it’s outside, those fields should stay hidden.
To implement this pattern, follow these steps:
1. Add a Grid
component to your Puck config
const config = {
components: {
//... existing config
// Define a Grid component
Grid: {
render: () => {
return (
<DropZone
zone="grid-zone"
style={{
display: "grid",
gridTemplateColumns: `repeat(3, 1fr)`,
}}
/>
);
},
},
},
};
2. Use the resolveFields
API on any component to dynamically show the columns
and rows
fields when placed inside a Grid parent, hiding them when the component is used outside a grid
const config = {
components: {
//... existing setup
AnyComponent: {
// Remove the default element wrapper
inline: true,
// Use dynamic fields to show "Columns" and "Rows" only when inside a Grid
resolveFields: (data, { parent }) => {
let fields = {
//... existing fields
};
if (parent?.type === "Grid") {
fields = {
...fields,
columns: { type: "number" },
rows: { type: "number" },
};
}
return fields;
},
// Render the component with the correct grid placement
render: ({ columns, rows, puck }) => {
return (
<div
ref={puck.dragRef}
style={{
gridColumn: `span ${columns || 1}`,
gridRow: `span ${rows || 1}`,
}}
>
Any Component
</div>
);
},
},
},
};
PRO TIP: If you find yourself repeating this setup multiple times for any components you wish to drop inside a grid, consider creating a withGrid(componentConfig)
function that adds the resolveFields
setup above to avoid code repetition
After setting this up, you can navigate to the editor, drop any component into any grid, adjust its column and row span, and see the layout update in real time. If the component is moved outside of the grid, those fields will disappear, keeping it adaptable to different layouts.
This pattern works great if you want to allow any item inside a grid while still controlling how each item is positioned. However, there are times when you might want to restrict users to a specific set of predefined grid layouts so they don’t have to worry about design details. For those cases, the Grid layout pattern is the best option.
Grid layout pattern
The Grid layout pattern allows users to choose from predefined grid layouts instead of manually adjusting each item's placement or setting up the layout themselves. This is useful when you want to streamline the page-building process and ensure design consistency while still offering flexibility.
The key difference from the Grid container - Any item and Grid container - Grid item patterns is that here, the grid itself determines how components are arranged based on multiple pre-implemented designs, rather than the nested components defining their own placement based on user input.
With this pattern, users can:
- Select a grid design from a predefined set of grid layouts
- Drop any components into the grids
- Maintain a clean and predictable design without manual configuration
To implement this pattern, follow these steps:
1. Define a Grid component with a layout
select field to allow users to choose a predefined grid design. Each option’s value should be the CSS class that implements that specific design
// Import designs
import "./Editor.css";
const config = {
components: {
Grid: {
fields: {
// Add a layout select input to choose a certain grid design
layout: {
type: "select",
options: [
{ label: "Hero", value: "hero-layout" },
{ label: "Content List", value: "content-list-layout" },
],
},
},
// Read the layout design to style the dropzone based on it
render: ({ layout }) => {
return (
<DropZone zone="grid-zone" className={`grid ${layout || ""}`} />
);
},
},
},
};
2. Define the CSS classes for your grid layouts so that each option applies a different design to the grid
/* ./Editor.css */
/* Base grid styles */
.grid {
display: grid;
}
/* Hero section layout */
.hero-layout {
grid-template-columns: 1fr 1fr;
gap: 16px;
}
/* Layout for a list of content */
.content-list-layout {
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
Once set up, users can drop components into a grid without worrying about manual positioning. They’ll be able to select a layout design from the list of options you provide and the grid items will automatically adjust to match the chosen design.
This is ideal for use cases where structure and consistency are important, like root-level page layouts, forms, or articles.
↩️ Flex patterns
Flexbox is all about dynamic, content-aware layouts. Unlike CSS Grids, which are great for structured designs with fixed tracks, Flexbox shines when you need elements to adjust based on their content and available space. It’s perfect for things like responsive lists and card layouts where items should naturally grow, shrink, or wrap as needed.
Once again, with Puck, you could give users full control over Flexbox CSS rules so that they can fine-tune every detail on their pages. But most of the time, when creating a page builder, you’ll want to abstract away from those complexities so that anyone can create pages without worrying about layout intricacies. After all, your user won’t always be a developer, and Flexboxes are famous for tripping over even the most seasoned front-end engineer from time to time.
That’s why, in this section, I’m sharing three patterns you can use to build intuitive, flexible page-building experiences with Flexbox in Puck.
Flex container pattern
The Flex container pattern is the simplest way to structure a layout using Flexbox in Puck, and it’s the basis of all other flex patterns. In this approach, the DropZone
itself defines the flex behavior, so all elements inside follow the container’s layout rules. This works well when you want a flexible, row-based or column-based structure where items automatically adjust based on their content and available space. It’s especially useful for things like horizontal navigation bars, stacked sections, or dynamic lists.
To set it up, follow these steps:
1. Define a new component in your Puck config for the Flex container
const config = {
components: {
// Define your flex container
FlexContainer: {
//... next steps here
},
},
};
2. Optionally, add fields to let users configure the flex behavior—for example, you might allow them to control flex-direction
const config = {
components: {
FlexContainer: {
// Add fields to configure flex behavior
fields: {
direction: {
type: "select",
options: [
{ label: "Row", value: "row" },
{ label: "Column", value: "column" },
],
},
},
},
},
};
3. Render a DropZone
inside the Flex container, applying display: flex
and any user-defined settings
const config = {
components: {
FlexContainer: {
//... existing config
// Render the DropZone as a flex container with user-defined settings
render: ({ direction = "row" }) => {
return (
<DropZone
zone="flex-zone"
style={{
display: "flex",
flexWrap: "wrap",
flexDirection: direction,
}}
/>
);
},
},
},
};
If you followed these steps, you should now be able to navigate to the editor, drop a Flexbox component, add some items inside, modify the direction of the container, and see the elements automatically arrange according to the user’s settings:
This pattern is great for simple, responsive layouts where all items flow naturally within a flexible container. If you need more control over individual item behavior—such as setting different flex-grow or alignment rules per item—you’ll want to explore the Flex container - Flex item pattern next.
Flex container - Flex item pattern
The Flex container - Flex item pattern is a simple yet powerful way to structure layouts using Flexbox. With it, you define a Flex container that holds Flex items where each item acts as a section where users can drop other components. This makes it useful for setting up flexible page structures—like a sidebar and main content area—or components that need to adapt to different screen sizes, such as a card with three horizontal sections that wrap when space is limited.
The key to this pattern is introducing a Flex Item component that wraps each child inside the Flex container. This component, then, provides controls for flex-grow
, flex-shrink
, and flex-basis
, allowing users to tweak layout behavior visually inside the editor. With that in place you would allow users to:
- Drag and drop Flex Item zones inside a Flex container
- Control how each zone should grow, shrink, and take up space
- Organize the layout visually by dragging and dropping zones around
- Drag and drop content into each zone
To implement this pattern, follow these steps:
1. Define a new component in your Puck config for the Flex container
const config = {
components: {
// Define a Flex container component
FlexContainer: {
render: () => {
return (
<DropZone
zone="flex-zone"
style={{
display: "flex",
flexWrap: "wrap"
}}
/>
);
},
},
},
};
2. Restrict the container to only allow FlexItems
inside
const config = {
components: {
FlexContainer: {
render: () => {
return (
<DropZone
zone="flex-zone"
// Only allow FlexItem components inside
allow={["FlexItem"]}
style={{
display: "flex",
flexWrap: "wrap"
}}
/>
);
},
},
},
};
3. Define the FlexItem
component
const config = {
components: {
FlexItem: {
// Remove the default wrapper around the component to style it as a child
// of a flexbox
inline: true,
},
},
};
4. Add fields for controlling the flex properties of the FlexItems
const config = {
components: {
FlexItem: {
fields: {
grow: { type: "number" },
shrink: { type: "number" },
basis: { type: "text" },
},
},
},
};
5. Render the DropZone
inside the FlexItem
, applying any required flex properties
const config = {
components: {
FlexItem: {
render: ({ grow, shrink, basis, puck }) => {
return (
<DropZone
ref={puck.dragRef}
zone="flex-item-zone"
style={{
flexGrow: grow ?? 0,
flexShrink: shrink ?? 1,
flexBasis: basis ?? "auto",
}}
/>
);
},
},
},
};
6. Finally, prevent nesting Flex Items inside each other
const config = {
components: {
FlexItem: {
render: ({ grow, shrink, basis, puck }) => {
return (
<DropZone
disallow={["FlexItem"]} // Prevents nesting
ref={puck.dragRef}
zone="flex-item-zone"
style={{
flexGrow: grow,
flexShrink: shrink,
flexBasis: basis,
}}
/>
);
},
},
},
};
Once set up, you’ll be able to drop a FlexContainer
onto the page, add FlexItems
inside, and adjust their behavior using the flex properties. You can then place any other components inside these sections, letting them adapt naturally within the layout.
This pattern is ideal when you need a flexible page layout, but if you’re looking for a way to add any component inside a flex container and still be able control it’s flex properties dynamically, the Flex container - Any item pattern might be a better fit.
Flex container - Any item pattern
The Flex container - Any item pattern is a more adaptable approach to using flex layouts, allowing any component to be placed inside while giving users control over how each item behaves within the flex container.
With this pattern, users can:
- Drop any component inside a flex container
- Control how items grow, shrink, and take up space
- Keep components fully functional outside of flex containers by hiding flex-specific controls
The key here is using dynamic fields to expose flex properties like flex-grow
, flex-shrink
, and flex-basis
only when a component is inside a Flex container. Outside of it, these fields should remain hidden to avoid unnecessary complexity.
To implement this pattern, follow these steps:
1. Define a new component in your Puck config for the Flex container
const config = {
components: {
// Define a Flex container component
FlexContainer: {
render: () => {
return (
<DropZone
zone="flex-zone"
style={{
display: "flex",
flexWrap: "wrap",
}}
/>
);
},
},
},
};
2. Define any component that can be placed inside a flex container, dynamically adjusting its flex related fields based on the parent container
const config = {
components: {
//... existing setup
AnyComponent: {
// Remove the default element wrapper
inline: true,
// Use dynamic fields to show "Flex Grow," "Flex Shrink," and "Flex Basis"
// only when inside a FlexContainer
resolveFields: (data, { parent }) => {
let fields = {
//... existing fields
};
if (parent?.type === "FlexContainer") {
fields = {
...fields,
flexGrow: { type: "number" },
flexShrink: { type: "number" },
flexBasis: { type: "text" },
};
}
return fields;
},
// Render the component with the configured flex behavior
render: ({ flexGrow, flexShrink, flexBasis, puck }) => {
return (
<div
ref={puck.dragRef}
style={{
flexGrow: flexGrow ?? 1,
flexShrink: flexShrink ?? 1,
flexBasis: flexBasis ?? "auto",
}}
>
Any Component
</div>
);
},
},
},
};
PRO TIP: If you plan to use this setup for multiple components, consider creating a withFlex(componentConfig)
function that adds the resolveFields
setup above to avoid code repetition
Once set up, users can drop any component into a flex container, tweak its growth and shrink behavior, and see changes in real-time. If the component is moved outside of the flex container, those controls will disappear, keeping it adaptable for different layouts.
This pattern is great for flexible, dynamic layouts where elements need to adjust their sizes based on available space.
🔷 Best practices
Beyond the patterns we’ve covered for integrating CSS Grid and Flexbox, there are a few best practices or tips that can help improve the overall drag-and-drop experience and make your editor feel smoother and more intuitive.
Add dynamic padding for editing
When working with nested layouts in the editor, selecting the right component with your cursor can get tricky—especially if DropZones are tightly packed. A simple way to improve usability is by adding space around DropZones only when the page is being edited. To do this, you can use the puck.isEditing
render prop, to add temporarily padding around DropZones, making it easier to grab and move entire groups of nested components without accidentally selecting inner ones:
const config = {
components: {
NestedComponent: {
render: ({ puck }) => {
return (
<DropZone
zone="dropzone-with-padding"
// If rendering in the editor, add padding, otherwise don't add any
styles={{ padding: puck.isEditing ? "16px" : undefined }}
/>
);
},
},
},
};
Prevent unwanted layout shifts
Nothing disrupts a drag-and-drop experience more than unexpected layout shifts while dragging elements around. To keep things predictable, you can use the allow
and disallow
props on your DropZones to control where components can be placed, that way, you can prevent seeing whole lists or containers move elements around when you just wanted to move a page header to the top of your canvas:
const config = {
components: {
Header: {
render: () => {
return <h1>My header</h1>;
},
},
ProductsList: {
render: ({ puck }) => {
return (
<DropZone
zone="product-zone"
// Only allow product items in this zone
allow={["ProductItem"]}
/>
);
},
},
ProductItem: {
render: () => {
return <div>Product</div>;
},
},
},
};
We used this in the Grid container - Grid item and Flex container - Flex item patterns to avoid nesting of sections and provide a better experience when moving them around the container.
Give hints with collisionAxis
Use the DropZone collisionAxis
prop to give Puck a hint about which direction your components flow.
By default, Puck will automatically identify the most likely drag direction based on your DropZone’s CSS properties, but sometimes you might have particular behavior that Puck can’t interpret. For example, if you have a flex container that doesn’t wrap, setting an x
(horizontal) collision axis will help it to know where it should try to add new elements:
const config = {
components: {
Example: {
render: () => {
return (
<DropZone
zone="my-content"
collisionAxis="x"
style={{ display: "flex", flexWrap: "noWrap" }}
/>
);
},
},
},
};
Ready to start building with Puck?
In this article, we covered some key patterns and best practices to help you build intuitive, flexible visual editors with Puck and take advantage of all the new features v0.18 introduces. Whether you're working with grids or flex layouts, these techniques should give you a solid foundation to create great editing experiences.
We’d love to hear what you're building! If you have questions, want to suggest improvements, contribute, or just bounce around ideas, here’s how you can get involved:
🌟 Star Puck on GitHub to show support and help others discover it.
🤝 Join our Discord to chat with the community and share your work.
📢 Follow Puck on X and Bluesky for updates, sneak peeks, and tips.
Have thoughts or feedback? Drop a comment—we’re always up for a good discussion.
Happy building! 🚀
Top comments (0)