The grid layout for our dashboard
The grid layout for dashboard.spatie.be was conceived with 3 simple ideas in mind:
- The layout has to be lightweight on the client side: no JavaScript calculations or full-blown grid library should be used.
- The HTML structure in the templates has to be clean, without extra markup for rows and columns.
- An author can rearrange components very easily, without having to worry too much about widths and heights, or component sequence.
Since the project uses Laravel, we ended up with a Blade view with Vue components that have a very simple grid property.
//dashboard.blade.php
...
<last-fm grid="b1:c1"></last-fm>
<current-time grid="d1"></current-time>
...
Let's see how this works.
Lay it out in Excel®
After playing around with a more classic grid system with rows and columns, the spreadsheet analogy came up: if you look at the dashboard as a spreadsheet, each component covers an area in this spreadsheet that can be defined by one or two cells.
Take a dashboard with 4 columns and 3 rows —like our example on GitHub:
a b c d
—————————————————————
1 | a1 | b1 | c1 | d1 |
—————————————————————
2 | a2 | b2 | c2 | d2 |
—————————————————————
3 | a3 | b3 | c3 | d3 |
—————————————————————
By addressing each cell with a combination of a column and row, you can now define eg.:
- The upcoming calendar as cells `a1 x a2`
- the Last.fm widget as cells `b1 x c1`
- The clock as cell `d1`
Placing the components this way has a few advantages over a classis HTML grid system: the simple notation, random order and possible expansion.
Short notation
We only need 2 values to describe both position and size (not 3 or more like position + width + height definitions). For components that cover a single cell —like the clock— even the single definition d1 is enough.
Source order
The source order of components in HTML doesn't matter —like in float-based grids. Each component is layed out independently, which allows for quick changes.
Expansion
Grid expansion (to the right or bottom) is easy. If a new column E or row 4 is added, all existing tiles can stay the same. You wouldn't have to rename existing classes to width-fifth
instead of width-fourth
and reworks like that.
BEM classes
To define a spreadsheet area in HTML, we'll be using a BEM notation. Take the Last.fm tile from the above screenshot; it can be defined by:
- a base class `grid` for absolute positioning and overflow
- two modifiers for the covered area: `grid--from-b1` and `grid--to-c1`
<div class='grid grid--from-b1 grid--to-c1'>
<!-- Last.fm data -->
</div>
In CSS the spreadsheet analogy translates nicely to the use of top/left
and bottom/right
properties for each cell. No width
or height
property is needed.
.grid {
position: absolute;
display: none; // hide by default
overflow: hidden;
}
.grid--from-b1 {
display: block;
left: 25%;
top: 0%;
}
.grid--to-c1 {
right: 25%;
bottom: 66.6666666667%;
}
Note: a grid cell is hidden by default until is has some valid modifiers (to hide tiles with an invalid configuration).
Sass loops
The generation of all CSS modifiers is done by 2 nested Sass loops. First we define our rows and cols and derive width and height for each cell. Since we'll be using letters for column names, we also prepare a Sass alphabet map with letters to loop through.
$grid-cols: 4;
$grid-rows: 3;
$grid-col-names: (a, b, c, d, e, f, g, h, i, j, k, l); // add more if needed
$cell-width: percentage(1/$grid-cols);
$cell-height: percentage(1/$grid-rows);
Now we are going to loop through rows and columns and generate the grid--from-
and grid--to-
modifiers for each letter & number combination.
@for $row from 1 through $grid-rows {
@for $col from 1 through $grid-cols {
// from modifier
@include modifier(from-#{nth($grid-col-names, $col)}#{$row}) {
display: block;
left: ($col - 1) * $cell-width;
top: ($row - 1) * $cell-height;
}
// to modifier
@include modifier(to-#{nth($grid-col-names, $col)}#{$row}) {
right: 100% - $col * $cell-width;
bottom: 100% - $row * $cell-height;
}
}
}
The modifier()
mixin is just a small helper that generates the correct BEM class names when nested.
@mixin modifier($name){
&--#{$name} {
@content;
}
}
And that's about the entire code for the generation of the grid CSS. Let's have a look at the HTML next.
Vue components
The final step is the definition of custom HTML components and properties, so an author can work with the friendly spreadsheet notation instead of the BEM syntax. Shorthand notation for a single cell should also be supported.
<!-- friendly input -->
<last-fm grid="b1:c1"></last-fm>
<current-time grid="d1"></current-time>
<!-- generated output -->
<div class='grid grid--from-b1 grid--to-c1'></div>
<div class='grid grid--from-d1 grid--to-d1'></div>
Vue is an excellent framework for these custom HTML components with their own tags and properties.
last-fm.js
Let's take the Last.fm tile once more.
<last-fm grid="b1:c1"></last-fm>
The Last.fm component in turn uses a shared grid subcomponent —used by all tiles— so we can use a <grid></grid>
notation in the Vue template.
The value of the grid
property of the Last.fm tile in the Blade template is bound to the position
property of this grid subcomponent.
import Grid from './grid';
export default {
template: `
<grid :position="grid">
<section>
</section>
</grid>
`,
components: {
Grid,
},
props: ['grid'],
};
grid.js
Next, the grid component itself uses his position
property with a custom Vue filter to transform the spreadsheet notation to the final output CSS classes we need.
The <slot>
renders the content of the parent component.
export default {
template: `
<div :class="position | grid-from-to">
<slot></slot>
</div>
`,
props: ['position'],
};
vue-filters.js
The Vue filter grid-from-to
uses a helper function that is imported from helper.js
and
translates the input "b1:c1"
to "grid grid--from-b1 grid--to-c1"
.
import Vue from 'vue';
import { gridFromTo } from './helpers';
Vue.filter('grid-from-to', gridFromTo);
Note: all filter functions are extracted to a helper file so they can be reused in other ways as well ( this and other cool ES6 syntax tips came from our Sebastian).
helpers.js
The function gridFromTo()
first splits the input "b1:c1"
in an array "['from-b1', 'to-c1']"
.
When you use the shorthand "d1"
for a single tile, it adds a default end value and translates to "['from-d1', 'to-d1']"
.
It then uses a second function modifyClass()
(also used in other tags throughout the project) that modifies a base CSS class with modifiers, and outputs the correct BEM syntax.
const gridFromTo = value => {
const [ from, to = from ] = value.toLowerCase().split(':');
return modifyClass([`from-${from}`, `to-${to}`], 'grid');
};
const modifyClass = (modifiers, base) => {
if (!modifiers) {
return base;
}
modifiers = Array.isArray(modifiers) ? modifiers : modifiers.split(' ');
modifiers = modifiers.map(modifier => `${base}--${modifier}`);
return [base, ...modifiers];
};
At last!
And there we have it:
- A simple spreadsheet style notation in the dashboard Blade view
- Translation from Vue components with a Vue filter to BEM classes in the output HTML
- Matching CSS classes generated by 2 nested Sass loops
Check out the entire project at GitHub.
Improvements
Exit CSS
One could argue that the use of these classes in CSS is overkill, since the stylesheet won't be shared with other pages, and each positioning class is used only once at best. You could calculate the same spreadsheet positioning in Vue with inline styles. I used a stylesheet anyway for the rest of the styling, and knew my way in Sass long before Vue, so this seemed to be the easiest route. Plus, playing around with the BEM classes in a browser inspector is quite handy to test layouts.
.env
Columns and row settings are defined in Sass at this point. It could be an improvement to have these in your .env
as well with all other dashboard settings, and pass them to Sass one way or an other, but I'm not sure if this is possible without custom Sass functions.
Let me know what you think!
What are your thoughts on "The grid layout for our dashboard"?