Stack Components with CSS Grid
7 min readGrid is praised for its two-dimensional layout capabilities but gets overshadowed by Flexbox for one-dimensional layouts. In this post, we'll look at how we can unlock advance layouts using components.
In design, space is considered a first-class element. One of my favorite quotes is from the famous graphic designer Jan Tschichold:
White space is to be regarded as an active element, not a passive background.
Space is often an afterthought in web development. When hearing the word spacing, you may immediately think of padding or margin. Recently we’ve seen more importance around space with properties like gap, but for now, we can only practically use this with Grid, while Flexbox is left to wait for better support or use a polyfill. Even then, these still lack priority for more advanced properties like minimum and maximum constraints or fractional units.
What we'll be building
In the previous article, we talked about how Stack components are a better paradigm for one-dimensional layouts, and we briefly discussed a tool called Subform that took a new approach to layout. While only being on the scene for a short time, the paradigms created in this tool greatly simplified how we think about positioning items in relation to each other.
We’ll be using an inspired API to control the spacing between and around children on our main axis. Follow along with this example to learn how we can build more advanced one-dimensional layouts:
The power of components
With components, we can encapsulate our styling logic in a first-class API, not have to wait for better support, and unlock more advanced ways to style our applications.
One of the cons mentioned in the Subform implementation was having to ship a custom layout engine. This is a good reason not to have to load extra code, especially Javascript, just to render something that looks like Flexbox. However, with component models being standard in today’s development workflows, we can take an interface similar to Subform and map it to whatever we want. While Houdini is still a ways off from having proper browser support for custom layouts, CSS Grid has the power we need to accomplish most of the same ideas. Instead of being able to control space on both our main and cross axes, we will only be dealing with the main axis today.
Getting the API right
We’ll start with the interface for controlling styles through our component:
1interface StackProps {2 axis?: 'horizontal' | 'vertical'3 width?: Number | String4 height?: Number | String5 alignment?: 'start' | 'center' | 'end'6 spaceStart?: Number | String7 spaceBetween?: Number | String8 spaceEnd?: Number | String9 background?: String10 foreground?: String11 children: React.ReactNode12}
Only three of our props specifically deal with space. Notice we don’t have margin or padding defined. Thinking about space as an element lends itself to an extremely simplified way of describing layout and, at the same time, unlocks the full constraint system that width and height use. Another problem this solves is that the gap
property doesn’t have a way to use dividers, colors, or backgrounds in gaps. Yet another reason to think of space as an element.
Like Kevin mentions in his article, this dramatically simplifies our layout API. Concepts are consolidated and easy to learn. How many times did you need to look up documentation for Flexbox or Grid?
Again, the most significant benefit of this compared to current spacing techniques through padding, margin, or gap are we can use the same constraints we use for other elements like minmax
, fr
units, and anything else an element can use.
Grid to the rescue
Since Grid implements parent-controlled layout, we can map through children in our component to extract the proper information to build up our layout to suffice the API we want:
1const isHorizontal = axis === 'horizontal'2const trackCells = React.Children.toArray(children).reduce(3 (cells, element, index) => {4 const cell = {5 element,6 size: React.isValidElement(element)7 ? (isHorizontal ? element.props.width : element.props.height) || 'auto'8 : null,9 }10 return index === 0 || spaceBetween === undefined11 ? cells.concat(cell)12 : [...cells, { size: spaceBetween }, cell]13 },14 []15)16if (spaceStart) {17 trackCells.unshift({ size: spaceStart })18}19if (spaceEnd) {20 trackCells.push({ size: spaceEnd })21}
First, we determine the direction of our main axis. Next, we loop through all children to determine what size each column or row should be. If the child has not defined a size, then we set it to auto
. While looping through, if we have set spaceBetween
we will insert that information in between each child while we build up our array. Finally, we add spaceStart
and spaceEnd
to their respective positions in the array if they are defined.
This gives us a nice collection to work with similar to the following:
1const trackCells = [2 { size: 'minmax(20px, 1fr)' },3 { element: { $$typeof: Symbol(react.element) }, size: 'minmax(100px, 1fr)' },4 { size: '20px' },5 { element: { $$typeof: Symbol(react.element) }, size: 'minmax(100px, 1fr)' },6 { size: 'minmax(20px, 1fr)' },7]
Now that we have the proper information for the constraints of our layout we can build up our Grid styles, notice we create columns/rows that are specifically used for space:
1trackCells2 .map((cell) => (typeof cell.size === 'number' ? `${cell.size}px` : cell.size))3 .join(' ')45// minmax(20px, 1fr) minmax(100px, 1fr) 20px minmax(100px, 1fr) minmax(20px, 1fr)
Since we are creating implicit columns/rows solely for spacing, we need to position the children accordingly. This is a nuanced problem and unfortunately forces the children to be aware of the implementation. We’ll use React Context to pass this information down:
1trackCells.map((cell, index) => (2 <StackContext.Provider3 value={{4 [`grid${isHorizontal ? 'Column' : 'Row'}`]: index + 1,5 }}6 >7 {cell.element}8 </StackContext.Provider>9))
Put it all together, and we have a Stack component that can define advance spacing constraints:
1const StackContext = React.createContext(null)23function Stack({4 axis,5 width,6 height,7 alignment,8 spaceStart,9 spaceBetween,10 spaceEnd,11 background,12 foreground,13 children,14 ...restProps15}) {16 const isHorizontal = axis === 'horizontal'17 const cellStyles = React.useContext(StackContext)18 const trackCells = React.Children.toArray(children).reduce(19 (cells, element, index) => {20 const cell = {21 element,22 size: React.isValidElement(element)23 ? (isHorizontal ? element.props.width : element.props.height) ||24 'auto'25 : null,26 }27 return index === 0 || spaceBetween === undefined28 ? cells.concat(cell)29 : [...cells, { size: spaceBetween }, cell]30 },31 []32 )33 if (spaceStart) {34 trackCells.unshift({ size: spaceStart })35 }36 if (spaceEnd) {37 trackCells.push({ size: spaceEnd })38 }39 return (40 <div41 style={{42 display: 'grid',43 gridAutoFlow: axis === 'horizontal' ? 'column' : 'row',44 [`gridTemplate${isHorizontal ? 'Columns' : 'Rows'}`]: trackCells45 .map((cell) =>46 typeof cell.size === 'number' ? `${cell.size}px` : cell.size47 )48 .join(' '),49 [`${isHorizontal ? 'align' : 'justify'}Items`]: alignment,50 color: foreground,51 background,52 width,53 height,54 ...cellStyles,55 }}56 {...restProps}57 >58 {trackCells.map((cell, index) => (59 <StackContext.Provider60 value={{61 [`grid${isHorizontal ? 'Column' : 'Row'}`]: index + 1,62 }}63 >64 {cell.element}65 </StackContext.Provider>66 ))}67 </div>68 )69}
Summary
While this solution might not be entirely practical yet, simplifying how we approach styling not just on the web, but throughout the industry lowers the barrier to entry for more people to be able to express themselves. If we can consolidate API surfaces and reduce nuances, we can think about more significant problems at hand, like user experience and accessibility. If anything, I hope this article can help shed light on how to start thinking of space as a first-class element in your design and development endeavors.
- design
- development
- css
- grid
- flexbox
- react