Search

CSS Fan Out with Grid and @property

A “fan out” is an expanding animation where a group of items appear one after another, next to each other, as though they were spread out from a stack. There’s usually a subtle bounciness in the reveal.

The effect is customarily achieved by timing and positioning each of the items individually with very specific hard set values. That can be an awful lot of work though. We can make things a bit easier if we let the items’ parent container do this for us. Here’s a result of doing it this way: 

UPDATE: This article has been updated to now include the animation of the grid items’ height, to produce an overall smoother transition effect. The previous version of this article didn’t cover that. 

For HTML, there’s a group of items (plus an empty one — I will explain later why it’s there), bookended by two radio controls to prompt the opening and closing of the items respectively.

<section class="items-container">
  <p class="items"><!--empty--></p>
  <label class="items close">
    Close the messages<input type="radio" name="radio">
  </label>
  <p class="items">Alert from Project X</p>
  <p class="items">&#x1F429; Willow's appointment at <i>Scrubby's</i></p>
  <p class="items">Message from (-_-)</p>
  <p class="items">NYT Feed: <u>Weather In... (Read more)</u></p>
  <p class="items">6 more items to check in your vacation list!</p>
  <label class="items open">
    Show the messages<input type="radio" name="radio">
  </label>
</section>Code language: HTML, XML (xml)

We need a grid container for this to work, so let’s turn the <section>, the items’ container element, into one. You could use a list or anything you feel is semantically appropriate.

.items-container {
  display: grid; 
}Code language: CSS (css)

Now create an Integer CSS custom property with a value same as the number of items inside the container (including the open and close controls, and the empty item). This is key to implement the revealing and hiding of the items, sequentially, from within the grid container’s style rule.

Also, register another CSS custom property of data type length that’ll be used to animate each item’s height during the opening and closing of the control, for a smoother execution of the overall action. 

@property --int {
  syntax: "<integer>";
  inherits: false;
  initial-value: 7;
}

@property --hgt {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}Code language: CSS (css)

Use the now created --int and --hgt properties to add that many grid rows of zero height in the grid container. 

.items-container {
  display: grid; 
  grid-template-rows: repeat(calc(var(--int)), var(--hgt));  
}
Code language: CSS (css)

When directly adding --int to repeat() it was producing a blotchy animation in Safari for me, so I fed it through calc() and the animation executed well (we’ll look into the animation in a moment). However, calc() computation kept leaving out one item in the iteration, because of how it computed the value 0. Hence, I added the empty item to compensate the exclusion. 

If Safari did not give me a blotchy result, I would’ve not needed an empty item, --int’s initial-value would’ve been 6, and grid-template-rows’s value would’ve been just repeat(var(--int), 0px). In fact, with this set up, I got good animation results both in Firefox and Chrome. 

In the end though, I went with the one that uses calc(), which provided the desired result in all the major browsers. 

Let’s get to animation now:

@keyframes open { to { --int: 0; --hgt:60px;} }
@keyframes close { to { --int: 6; --hgt:0px;} } 
.item-container {
  display: grid; 
  grid-template-rows: repeat(calc(var(--int)), var(--hgt)); 
  &:has(.open :checked) {
  /* open action */
    animation: open .3s ease-in-out forwards;
    .open { display: none; }
  }
  &:has(.close :checked) {
  /* close action */
    --int: 0;
    --hgt: 60px;
    animation: close .3s ease-in-out forwards;
  }
}
Code language: JavaScript (javascript)

When the input is in the checked state, the open keyframe animation is executed, and the control itself is hidden with display: none

The open class changes --int’s value from its initial-value, 7, to the one set within the @keyframes rule (0), over a set period (.3s). This decrement removes the zero height from each of the grid row, one by one, thus sequentially revealing all the items in .3s or 300ms. Simultaneously, --hgt’s value is increased to 60px from its initial 0px value. This expands each item’s height as it appears on the screen. 

When the input to hide all the items is in the checked state, the close keyframe animation is executed, setting --int’s value to 0 and --hgt’s value to 60px.

The close class changes the now 0 value of --int to the value declared in its rule: 7. This increment sets a zero height to each of the grid row, one by one, thus sequentially hiding all the items. Simultaneously, --hgt’s value is decreased to 0px. This shrinks each item’s height as it disappears from the screen. 

To perform the close action, instead of making a unique close animation, I tried using the open animation with animation-direction: reverse. Unfortunately, the result was jerky. So I kept unique animations for the open and close actions separately.

Additionally, to polish the UI, I’m adding transition animations to the row gaps and text colors as well. The row gaps set cubic-bezier() animation timing function to create a low-key springy effect. 

.scroll-container {
  display: grid; 
  grid-template-rows: repeat(calc(var(--int)), 0px); /* serves the open and close actions */
  transition: row-gap .3s .1s cubic-bezier(.8, .5, .2, 1.4);
  &:has(.open :checked) {
    /* open action */
    animation: open .3s ease-in-out forwards;
    .open { display: none; }
    /* styling */
    row-gap: 10px;
    .items { color: rgb(113 124 158); transition: color .3s .1s;}
    .close { color: black }
  }
  &:has(.close :checked) {
    /* close action */
    --int: 0;
    animation: close .3s ease-in-out forwards;
    /* styling */
    row-gap: 0;
    .items { color: transparent; transition: color .2s;}
  }
}
Code language: CSS (css)

When expanded, the row gaps go up to 10px and the text color comes in. When shrinking, the row gaps go down to 0 and the text color fades out to transparent. With that, the example is complete! Here’s the Pen once more:

Note: You can try this method with any grid compositions — rows, columns, or both.

Further Reading

Wanna learn modern CSS layout?

Frontend Masters logo

We have an in-depth course on Advanced CSS Layouts that digs into the big stuff like grid and flexbox, and then more like custom properties and calc(), using them for advanced layouts and practical solutions.

3 responses to “CSS Fan Out with Grid and @property”

  1. Avatar Chris Coyier says:

    I think this is clever stuff. “Fanning out” but making CSS grid do all the layout work is probably how I would approach this should I need to build it.

    One thing I noticed, and I mentioned it to Preethi too, is that the animation isn’t quite that buttery smooth feel that is ideal for any kind of screen movement. And I think it boils down to the fact the the animation, in this particular demo, is essentially animating an integer from 0 to 7, and thus can only really have 7 keyframes. That’s approaching “enough” for animation that is only a fraction of a second anyway, but having an animation that is dependent on the number of items is probably not ideal.

    It would be fun homework to approach this another way. My mind went right to using View Transitions to just code the two states, open and closed, and use that to animate between them, which I would think would come out smoother.

Leave a Reply

Your email address will not be published. Required fields are marked *

Frontend Masters ❤️ Open Source

Did you know? Frontend Masters Donates to open source projects. $313,806 contributed to date.