CSS Can Do That?!

An experiment; a demo; a guide

Avoiding Repetition

Consider...
 section.landing-page .first-dohickey {
   animation: fade-in 1s;
 }
 section.landing-page .second-dohickey {
   animation: flash-in 1s;
 }
 section.landing-page .third-dohickey {
   animation: swirl-in 1s;
 }
 section:not(.landing-page):target ~ section.landing-page .first-dohickey {
   animation: fade-out 1s;
 }
 section:not(.landing-page):target ~ section.landing-page .second-dohickey {
   animation: flash-out 1s;
 }
 section:not(.landing-page):target ~ section.landing-page .third-dohickey {
   animation: swirl-out 1s;
 }
This CSS animates the section.landing-page dohickey children when the home page for this site is shown or hidden. (Well, there are no .*-dohickey elements on the home page, but if there were this is how we could animate them -- see more in Page Animations.)

Problem: That's confusing and hard to change. Imagine writing this for multiple pages (not just the landing page), then discovering you need to change the section:not(.landing-page):target ~ section.landing-page selector? Eugh.

Alternative: set/unset a CSS custom property once for the top level selector, and use it inside each of the .*-dohickey selectors.

Use two custom properties, one for indicating we should animate in, another for indicating we should animate out

unset indicates a truthy value, none indicates a falsy value.

For each .*-dohickey, coalesce the relevant variable. If it's none, there's nothing to do because the variable is already set, and animation: none; is a noop. If it's unset then the variable has no value, and the var function will fallback to whatever coalesced value you provided. (CSS Working Group spec)

 section.landing-page {
   --when-animating-in: unset;
   --when-animating-out: none;
 }

 section:not(.landing-page):target ~ section.landing-page {
   --when-animating-in: none;
   --when-animating-out: unset;
 }

 .first-dohickey {
   animation:
     var(--when-animating-in, fade-in 1s),
     var(--when-animating-out, fade-out 1s);
 }

 .second-dohickey {
   animation:
     var(--when-animating-in, flash-in 1s),
     var(--when-animating-out, flash-out 1s);
 }

 .third-dohickey {
   animation:
     var(--when-animating-in, swirl-in 1s),
     var(--when-animating-out, swirl-out 1s);
 }

We have now separated the business of deciding when to animate in or out with the business of how to animate in or out.

See it in action

In your developer tools, search for docs_link_31919bf0 to find the relevant CSS rules running this site.

Changing Pages

Consider...
 <div class="page" id="page-1">
   Page 1 content
 </div>
 <div class="page" id="page-2">
   Page 2 content
 </div>
 <div class="page" id="home-page">
   Home page content
 </div>

Problem: How do you show only one page, and hide the rest? How do you link to one page in particular?

The :target pseudo-class matches the element whose id is the same as the current URL hash (MDN docs).

Hide all the .page elements except the active one.

 .page:not(:target) {
   display: none;
 }
Now we can select/change the active page by changing the URL hash -- with an anchor tag, or by arriving at the site with a certain hash already in the URL.
 <a href="#page-1">Page 1</a>

Problem: How do you show a home page when there is no URL hash?

In addition to hiding all pages that aren’t :targeted, hide the home page if it is preceded by any page that is :targeted.

  • This requires placing the home page last among all the page elements.
  • We can do this with the general sibling selector ~ (MDN docs)

Note that we also update the original .page:not(:target) to apply to pages except the home page – we show the home page even when it’s not targeted.

 .page:target ~ #home-page,
 .page:not(#home-page):not(:target) {
   display: none;
 }

See it in action

In your developer tools, search for docs_link_afcd9582 to find the relevant CSS rules running this site.

Why does the site code set CSS custom properties instead of display: none? Find out in Avoiding Repetition.

Page Animations

Consider...
 <div class="page" id="page-1">
   Page 1 content
 </div>
 <div class="page" id="page-2">
   Page 2 content
 </div>
 <div class="page" id="home-page">
   Home page content
 </div>
 #home-page,
 .page:target {
   animation: fade-in 1s;
 }

 .page:target ~ #home-page,
 .page:not(#home-page):not(:target) {
   animation: fade-out 1s;
 }

 @keyframes fade-in {
   from { opacity: 0; } 
   to { opacity: 1; }
 }

 @keyframes fade-out {
   from { opacity: 1; }
   to { opacity: 0; }
 }

When a page is no longer :targeted, it animates out. The next page that is :targeted is, in turn, animated in. (Refer to Changing Pages for an explanation of the #home-page stuff.)

Problem: when the page first loads, all but one page are not :targeted, so they animate out. Except that the page must be visible to animate out. The result is that every page is visible and overlaid one above the other, while all but one animates out. (demo of this behaviour.)

One approach to deal with this is to set a CSS custom property to hidden until all the animations are complete.

When the page loads, --hidden-while-initializing is hidden. We run the initialize animation to unset --hidden-while-initializing after a delay.

The initialize animation does nothing for the duration of the animation, and sets the to values at the end of the animation. (It behaves this way because of the steps(1, end) timing function, which divides the animation into a single step. MDN docs on steps() as a timing function

 :root {
   --hidden-while-initializing: hidden;
   animation: initialize 1s steps(1, end);
 }

 @keyframes initialize {
   to {
     --hidden-while-initializing: unset;
   }
 }

Note that at the time of writing this this seems to only work on Chrome — as discussed in this WC3 spec discussion, custom properties cannot be interpolated in animations. Once the @property rule is supported (it's not even on caniuse.com yet. See the GitHub request to add it to caniuse.com), that ought to make it possible to interpolate the values in animations.

I didn't include this JavaScript alternative in this demo so that I could say "zero JavaScript" on the landing page, but if you can't wait for the full browser support you can replace the initialize animation with the following JavaScript.

 document.addEventListener('DOMContentLoaded', () => {
   const initializationDelay = 1000
   setTimeout(() => {
     const rootStyle = document.documentElement.style
     rootStyle.removeProperty('--hidden-when-initializing')
     // also you can use:
     //   rootStyle.setProperty('--property-name', 'value')
     // if there are custom properties whose values you want to
     // change after the initialization delay
   }, initializationDelay)
 })

My first attempt involved using the approach described in DRYing up complex queries to change the value of animations based on the value of --hidden-while-initializing. (To do this, --hidden-while-initializing would have to start as none instead of hidden)

That can't work, because as soon as the value of animations changes, the new animation runs -- so all we're doing is delaying that flash of all the pages animating out.

Instead, change the @keyframes based on the value of --hidden-while-initializing.

While initializing, the page is hidden (visibility: hidden). Then, it's visible

 @keyframes fade-out {
   from {
     visibility: var(--hidden-while-initializing, visible);
     opacity: 1;
   }

   to {
     visibility: var(--hidden-while-initializing, visible);
     opacity: 0;
   }

See it in action

In your developer tools, search for docs_link_755def49 to find the relevant CSS rules running this site.

This site is a single HTML file, some CSS, but no JavaScript

I’ve written it to explore CSS features that could be used instead of JavaScript libraries, and to familiarize myself with CSS animations.

Along the way, I documented my discoveries and the patterns I’ve used. This site is both an example and documentation.

Demos/Docs/Patterns

These articles describe how this page is built — or you can read the relevant code in your developer tools by searching for the related docs_link.

Inspired By

Disclaimer

I don't believe we should start building static websites this way.

Then What's The Point?

By doing this exercise of writing an interactive site without JavaScript, I may find in the future that I need less JavaScript to do the same thing -- because there's more that you can do with CSS than I previously thought.

What I'm most likely to actually do with this is to pass state from JavaScript to CSS via custom properties, and then use that state to adjust CSS properties -- as opposed to calculating CSS properties in JavaScript and setting them as inline styles. I've explored that idea more in this CSS scroll animation codepen.

Contributing

You can find the source on Github — I welcome PRs and feedback. I'll add your name here if I merge your PR :)