Tag: Switching

Dynamically Switching From One HTML Element to Another in Vue

A friend once contacted me asking if I had a way to dynamically change one HTML element into another within Vue’s template block. For instance, shifting a <div> element to a <span> element based on some criteria. The trick was to do this without relying on a series of v-if and v-else code.

I didn’t think much of it because I couldn’t see a strong reason to do such a thing; it just doesn’t come up that often. Later that same day, though, he reached out again and told me he learned how to change element types. He excitedly pointed out that Vue has a built-in component that can be used as a dynamic element in the very way that he needed.

This small feature can keep code in the template nice and tidy. It can reduce v-if and v-else glut down to a smaller amount of code that’s easier to understand and maintain. This allows us to use methods or computed methods to create nicely-coded, and yet more elaborate, conditions in the script block. That’s where such things belong: in the script, not the template block.

I had the idea for this article mainly because we use this feature in several places in the design system where I work. Granted it’s not a huge feature and it is barely mentioned in the documentation, at least as far as I can tell. Yet it has potential to help render specific HTML elements in components.

Vue’s built-in <component> element

There are several features available in Vue that allow for easy dynamic changes to the view. One such feature, the built-in <component> element, allows components to be dynamic and switched on demand. In both the Vue 2 and the Vue 3 documentation, there is a small note about using this element with HTML elements; that is the part we shall now explore.

The idea is to leverage this aspect of the <component> element to swap out common HTML elements that are somewhat similar in nature; yet with different functionality, semantics, or visuals. The following basic examples will show the potential of this element to help with keeping Vue components neat and tidy.

Buttons and links are often used interchangeably, but there are big differences in their functionality, semantics, and even visuals. Generally speaking, a button (<button>) is intended for an internal action in the current view tied to JavaScript code. A link (<a>), on the other hand, is intended to point to another resource, either on the host server or an external resource; most often web pages. Single page applications tend to rely more on the button than the link, but there is a need for both.

Links are often styled as buttons visually, much like Bootstrap’s .btn class that creates a button-like appearance. With that in mind, we can easily create a component that switches between the two elements based on a single prop. The component will be a button by default, but if an href prop is applied, it will render as a link.

Here is the <component> in the template:

<component   :is="element"   :href="href"   class="my-button" >   <slot /> </component>

This bound is attribute points to a computed method named element and the bound href attribute uses the aptly named href prop. This takes advantage of Vue’s normal behavior that the bound attribute does not appear in the rendered HTML element if the prop has no value. The slot provides the inner content regardless whether the final element is a button or a link.

The computed method is simple in nature:

element () {   return this.href ? 'a' : 'button'; }

If there’s an href prop,. then an <a> element is applied; otherwise we get a <button>.

<my-button>this is a button</my-button> <my-button href="https://www.css-tricks.com">this is a link</my-button>

The HTML renders as so:

<button class="my-button">this is a button</button> <a href="https://www.css-tricks.com" class="my-button">this is a link</a>

In this case, there could be an expectation that these two are similar visually, but for semantic and accessibility needs, they are clearly different. That said, there’s no reason the two outputted elements have to be styled the same. You could either use the element with the selector div.my-button in the style block, or create a dynamic class that will change based on the element.

The overall goal is to simplify things by allowing one component to potentially render as two different HTML elements as needed — without v-if or v-else!

Ordered or unordered list?

A similar idea as the button example above, we can have a component that outputs different list elements. Since an unordered list and an ordered list make use of the same list item (<li>) elements as children, then that’s easy enough; we just swap <ul> and <ol>. Even if we wanted to have an option to have a description list, <dl>, this is easily accomplished since the content is just a slot that can accept <li> elements or <dt>/<dd>combinations.

The template code is much the same as the button example:

<component   :is="element"   class="my-list" >   <slot>No list items!</slot> </component>

Note the default content inside the slot element, I’ll get to that in a moment.

There is a prop for the type of list to be used that defaults to <ul>:

props: {   listType: {     type: String,     default: 'ul'   } }

Again, there is a computed method named element:

element () {   if (this.$  slots.default) {     return this.listType;   } else {     return 'div';   } }

In this case, we are testing if the default slot exists, meaning it has content to render. If it does, then the the list type passed through the listType prop is used. Otherwise, the element becomes a <div> which would show the “No list items!” message inside the slot element. This way, if there are no list items, then the HTML won’t render as a list with one item that says there are no items. That last aspect is up to you, though it is nice to consider the semantics of a list with no apparent valid items. Another thing to consider is the potential confusion of accessibility tools suggesting this is a list with one item that just states there are no items.

Just like the button example above, you could also style each list differently. This could be based on selectors that target the element with the class name, ul.my-list. Another option is to dynamically change the class name based on the chosen element.

This example follows a BEM-like class naming structure:

<component   :is="element"   class="my-list"   :class="`my-list__$  {element}`" >   <slot>No list items!</slot> </component>

Usage is as simple as the previous button example:

<my-list>   <li>list item 1</li> </my-list>  <my-list list-type="ol">   <li>list item 1</li> </my-list>  <my-list list-type="dl">   <dt>Item 1</dt>   <dd>This is item one.</dd> </my-list>  <my-list></my-list>

Each instance renders the specified list element. The last one, though, results in a <div> stating no list items because, well, there’s no list to show!

One might wonder why create a component that switches between the different list types when it could just be simple HTML. While there could be benefits to keeping lists contained to a component for styling reasons and maintainability, other reasons could be considered. Take, for instance, if some forms of functionality were being tied to the different list types? Maybe consider a sorting of a <ul> list that switches to a <ol> to show sorting order and then switching back when done?

Now we’re controlling the elements

Even though these two examples are essentially changing the root element component, consider deeper into a component. For instance, a title that might need to change from an <h2> to an <h3> based on some criteria.

If you find yourself having to use ternary solutions to control things beyond a few attributes, I would suggest sticking with the v-if. Having to write more code to handle attributes, classes, and properties just complicates the code more than the v-if. In those cases, the v-if makes for simpler code in the long run and simpler code is easier to read and maintain.

When creating a component and there’s a simple v-if to switch between elements, consider this small aspect of a major Vue feature.

Expanding the idea, a flexible card system

Consider all that we’ve covered so far and put it to use in a flexible card component. This example of a card component allows for three different types of cards to be placed in specific parts of the layout of an article:

  • Hero card: This is expected to be used at the top of the page and draw more attention than other cards.
  • Call to action card: This is used as a line of user actions before or within the article.
  • Info card: This is intended for pull quotes.

Consider each of these as following a design system and the component controls the HTML for semantics and styling.

In the example above, you can see the hero card at the top, a line of call-to-action cards next, and then — scrolling down a bit — you’ll see the info card off to the right side.

Here is the template code for the card component:

<component :is="elements('root')" :class="'custom-card custom-card__' + type" @click="rootClickHandler">   <header class="custom-card__header" :style="bg">     <component :is="elements('header')" class="custom-card__header-content">       <slot name="header"></slot>     </component>   </header>   <div class="custom-card__content">     <slot name="content"></slot>   </div>   <footer class="custom-card__footer">     <component :is="elements('footer')" class="custom-card__footer-content" @click="footerClickHandler">       <slot name="footer"></slot>     </component>   </footer> </component>

There are three of the “component” elements in the card. Each represents a specific element inside the card, but will be changed based on what kind of card it is. Each component calls the elements() method with a parameter identifying which section of the card is making the call.

The elements() method is:

elements(which) {   const tags = {     hero: { root: 'section', header: 'h1', footer: 'date' },     cta: { root: 'section', header: 'h2', footer: 'div' },     info: { root: 'aside', header: 'h3', footer: 'small' }   }   return tags[this.type][which]; }

There are probably several ways of handing this, but you’ll have to go in the direction that works with your component’s requirements. In this case, there is an object that keeps track of HTML element tags for each section in each card type. Then the method returns the needed HTML element based on the current card type and the parameter passed in.

For the styles, I inserted a class on the root element of the card based on the type of card it is. That makes it easy enough to create the CSS for each type of card based on the requirements. You could also create the CSS based on the HTML elements themselves, but I tend to prefer classes. Future changes to the card component could change the HTML structure and less likely to make changes to the logic creating the class.

The card also supports a background image on the header for the hero card. This is done with a simple computed placed on the header element: bg. This is the computed:

bg() {   return this.background ? `background-image: url($  {this.background})` : null; }

If an image URL is provided in the background prop, then the computed returns a string for an inline style that applies the image as a background image. A rather simple solution that could easily be made more robust. For instance, it could have support for custom colors, gradients, or default colors in case of no image provided. There’s a large number of possibilities that his example doesn’t approach because each card type could potentially have their own optional props for developers to leverage.

Here’s the hero card from this demo:

<custom-card type="hero" background="https://picsum.photos/id/237/800/200">   <template v-slot:header>Article Title</template>   <template v-slot:content>Lorem ipsum...</template>   <template v-slot:footer>January 1, 2011</template> </custom-card>

You’ll see that each section of the card has its own slot for content. And, to keep things simple, text is the only thing expected in the slots. The card component handles the needed HTML element based solely on the card type. Having the component just expect text makes using the component rather simplistic in nature. It replaces the need for decisions over HTML structure to be made and in turn the card is simply implemented.

For comparison, here are the other two types being used in the demo:

<custom-card type="cta">   <template v-slot:header>CTA Title One</template>   <template v-slot:content>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</template>   <template v-slot:footer>footer</template> </custom-card>  <custom-card type="info">   <template v-slot:header>Here's a Quote</template>   <template v-slot:content>“Maecenas ... quis.”</template>   <template v-slot:footer>who said it</template> </custom-card>

Again, notice that each slot only expects text as each card type generates its own HTML elements as defined by the elements() method. If it’s deemed in the future that a different HTML element should be used, it’s a simple matter of updating the component. Building in features for accessibility is another potential future update. Even interaction features can be expanded, based on card types.

The power is in the component that’s in the component

The oddly named <component> element in Vue components was intended for one thing but, as often happens, it has a small side effect that makes it rather useful in other ways. The <component> element was intended to dynamically switch Vue components inside another component on demand. A basic idea of this could be a tab system to switch between components acting as pages; which is actually demonstrated in the Vue documentation. Yet it supports doing the same thing with HTML elements.

This is an example of a new technique shared by a friend that has become s surprisingly useful tool in the belt of Vue features that I’ve used. I hope that this article carries forward the ideas and information about this small feature for you to explore how you might leverage this in your own Vue projects.


The post Dynamically Switching From One HTML Element to Another in Vue appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, , , , ,

Theming and Theme Switching with React and styled-components

I recently had a project with a requirement to support theming on the website. It was a bit of a strange requirement, as the application is mostly used by a handful of administrators. An even bigger surprise was that they wanted not only to choose between pre-created themes, but build their own themes. I guess the people want what they want!

Let’s distill that into a complete list of more detailed requirements, then get it done!

  • Define a theme (i.e. background color, font color, buttons, links, etc.)
  • Create and save multiple themes
  • Select and apply a theme
  • Switch themes
  • Customize a theme

We delivered exactly that to our client, and the last I heard, they were using it happily!

Let’s get into building exactly that. We’re going to use React and styled-components. All the source code used in the article can be found in the GitHub Repository.

The setup

Let’s set up a project with React and styled-components. To do that, we will be using the create-react-app. It gives us the environment we need to develop and test React applications quickly.

Open a command prompt and use this command to create the project:

npx create-react-app theme-builder

The last argument, theme-builder, is just the name of the project (and thus, the folder name). You can use anything you like.

It may take a while. When done, navigate it to it in the command line with cd theme-builder. Open the file src/App.js file and replace the content with the following:

import React from 'react';  function App() {   return (     <h1>Theme Builder</h1>   ); }  export default App;

This is a basic React component that we will modify soon. Run the following command from the project root folder to start the app:

# Or, npm run start yarn start

You can now access the app using the URL http://localhost:3000.

A simple heading 1 that says Theme Builder in black on a white background.

create-react-app comes with the test file for the App component. As we will not be writing any tests for the components as part of this article, you can choose to delete that file.

We have to install a few dependencies for our app. So let’s install those while we’re at it:

# Or, npm i ... yarn add styled-components webfontloader lodash

Here’s what we get:

  • styled-components: A flexible way to style React components with CSS. It provides out-of-the-box theming support using a wrapper component called, <ThemeProvider>. This component is responsible for providing the theme to all other React components that are wrapped within it. We will see this in action in a minute.
  • Web Font Loader: The Web Font Loader helps load fonts from various sources, like Google Fonts, Adobe Fonts, etc. We will use this library to load fonts when a theme is applied.
  • lodash: This is a JavaScript utility library for some handy little extras.

Define a theme

This is the first of our requirements. A theme should have a certain structure to define appearance, including colors, fonts, etc. For our application, we will define each theme with these properties:

  • unique identifier
  • theme name
  • color definitions
  • fonts
Screenshot of a code editor showing the organized structure of properties for a sea wave theme.
A theme is a structured group of properties that we’ll use in the application.

You may have more properties and/or different ways to structure it, but these are the things we’re going to use for our example.

Create and save multiple themes

So, we just saw how to define a theme. Now let’s create multiple themes by adding a folder in the project at src/theme and a file in it called, schema.json. Here’s what we can drop in that file to establish “light” and “sea wave” themes:

{   "data" : {     "light" : {       "id": "T_001",       "name": "Light",       "colors": {         "body": "#FFFFFF",         "text": "#000000",         "button": {           "text": "#FFFFFF",           "background": "#000000"         },         "link": {           "text": "teal",           "opacity": 1         }       },       "font": "Tinos"     },     "seaWave" : {       "id": "T_007",       "name": "Sea Wave",       "colors": {         "body": "#9be7ff",         "text": "#0d47a1",         "button": {           "text": "#ffffff",           "background": "#0d47a1"         },         "link": {           "text": "#0d47a1",           "opacity": 0.8         }       },       "font": "Ubuntu"     }   } }

The content of the schema.json file can be saved to a database so we can persist all the themes along with the theme selection. For now, we will simply store it in the browser’s localStorage. To do that, we’ll create another folder at src/utils with a new file in it called, storage.js. We only need a few lines of code in there to set up localStorage:

export const setToLS = (key, value) => {   window.localStorage.setItem(key, JSON.stringify(value)); }  export const getFromLS = key => {   const value = window.localStorage.getItem(key);    if (value) {     return JSON.parse(value);   } }

These are simple utility functions to store data to the browser’s localStorage and to retrieve from there. Now we will load the themes into the browser’s localStorage when the app comes up for the first time. To do that, open the index.js file and replace the content with the following,

import React from 'react'; import ReactDOM from 'react-dom'; import App from './App';  import * as themes from './theme/schema.json'; import { setToLS } from './utils/storage';  const Index = () => {   setToLS('all-themes', themes.default);   return(     <App />   ) }  ReactDOM.render(   <Index />   document.getElementById('root') );

Here, we are getting the theme information from the schema.json file and adding it to the localStorage using the key all-themes. If you have stopped the app running, please start it again and access the UI. You can use DevTools in the browser to see the themes are loaded into localStorage.

The theme with DevTools open and showing the theme properties in the console.
All of the theme props are properly stored in the browser’s localStorage, as seen in DevTools, under Application → Local Storage.

Select and apply a theme

We can now use the theme structure and supply the theme object to the <ThemeProvider> wrapper.

First, we will create a custom React hook. This will manage the selected theme, knowing if a theme is loaded correctly or has any issues. Let’s start with a new useTheme.js file inside the src/theme folder with this in it:

import { useEffect, useState } from 'react'; import { setToLS, getFromLS } from '../utils/storage'; import _ from 'lodash';  export const useTheme = () => {   const themes = getFromLS('all-themes');   const [theme, setTheme] = useState(themes.data.light);   const [themeLoaded, setThemeLoaded] = useState(false);    const setMode = mode => {     setToLS('theme', mode)     setTheme(mode);   };    const getFonts = () => {     const allFonts = _.values(_.mapValues(themes.data, 'font'));     return allFonts;   }    useEffect(() =>{     const localTheme = getFromLS('theme');     localTheme ? setTheme(localTheme) : setTheme(themes.data.light);     setThemeLoaded(true);   }, []);    return { theme, themeLoaded, setMode, getFonts }; };

This custom React hook returns the selected theme from localStorage and a boolean to indicate if the theme is loaded correctly from storage. It also exposes a function, setMode, to apply a theme programmatically. We will come back to that in a bit. With this, we also get a list of fonts that we can load later using a web font loader.

It would be a good idea to use global styles to control things, like the site’s background color, font, button, etc. styled-components provides a component called, createGlobalStyle that establishes theme-aware global components. Let’s set those up in a file called, GlobalStyles.js in the src/theme folder with the following code:

import { createGlobalStyle} from "styled-components";  export const GlobalStyles = createGlobalStyle`   body {     background: $ {({ theme }) => theme.colors.body};     color: $ {({ theme }) => theme.colors.text};     font-family: $ {({ theme }) => theme.font};     transition: all 0.50s linear;   }    a {     color: $ {({ theme }) => theme.colors.link.text};     cursor: pointer;   }    button {     border: 0;     display: inline-block;     padding: 12px 24px;     font-size: 14px;     border-radius: 4px;     margin-top: 5px;     cursor: pointer;     background-color: #1064EA;     color: #FFFFFF;     font-family: $ {({ theme }) => theme.font};   }    button.btn {     background-color: $ {({ theme }) => theme.colors.button.background};     color: $ {({ theme }) => theme.colors.button.text};   } `;

Just some CSS for the <body>, links and buttons, right? We can use these in the App.js file to see the theme in action by replace the content in it with this:

// 1: Import import React, { useState, useEffect } from 'react'; import styled, { ThemeProvider } from "styled-components"; import WebFont from 'webfontloader'; import { GlobalStyles } from './theme/GlobalStyles'; import {useTheme} from './theme/useTheme';  // 2: Create a cotainer const Container = styled.div`   margin: 5px auto 5px auto; `;  function App() {   // 3: Get the selected theme, font list, etc.   const {theme, themeLoaded, getFonts} = useTheme();   const [selectedTheme, setSelectedTheme] = useState(theme);    useEffect(() => {     setSelectedTheme(theme);    }, [themeLoaded]);    // 4: Load all the fonts   useEffect(() => {     WebFont.load({       google: {         families: getFonts()       }     });   });    // 5: Render if the theme is loaded.   return (     <>     {       themeLoaded && <ThemeProvider theme={ selectedTheme }>         <GlobalStyles/>         <Container style={{fontFamily: selectedTheme.font}}>           <h1>Theme Builder</h1>           <p>             This is a theming system with a Theme Switcher and Theme Builder.             Do you want to see the source code? <a href="https://github.com/atapas/theme-builder" target="_blank">Click here.</a>           </p>         </Container>       </ThemeProvider>     }     </>   ); }  export default App;

A few things are happening here:

  1. We import the useState and useEffect React hooks which will help us to keep track of any of the state variables and their changes due to any side effects. We import ThemeProvider and styled from styled-components. The WebFont is also imported to load fonts. We also import the custom theme, useTheme, and the global style component, GlobalStyles.
  2. We create a Container component using the CSS styles and styled component.
  3. We declare the state variables and look out for the changes.
  4. We load all the fonts that are required by the app.
  5. We render a bunch of text and a link. But notice that we are wrapping the entire content with the <ThemeProvider> wrapper which takes the selected theme as a prop. We also pass in the <GlobalStyles/> component.

Refresh the app and we should see the default “light” theme enabled.

The theme with a white background and black text.
Hey, look at that clean, stark design!

We should probably see if switching themes works. So, let’s open the useTheme.js file and change this line:

localTheme ? setTheme(localTheme) : setTheme(themes.data.light);

…to:

localTheme ? setTheme(localTheme) : setTheme(themes.data.seaWave);

Refresh the app again and hopefully we see the “sea wave” theme in action.

The same theme in with a blue color scheme with a light blue background and dark blue text and a blue button.
Now we’re riding the waves of this blue-dominant theme.

Switch themes

Great! We are able to correctly apply themes. How about creating a way to switch themes just with the click of a button? Of course we can do that! We can also provide some sort of theme preview as well.

A heading instructs the user to select a theme and two card components are beneath the heading, side-by-side, showing previews of the light theme and the sea wave theme.
A preview of each theme is provided in the list of options.

Let’s call each of these boxes a ThemeCard, and set them up in a way they can take its theme definition as a prop. We’ll go over all the themes, loop through them, and populate each one as a ThemeCard component.

{   themes.length > 0 &&    themes.map(theme =>(     <ThemeCard theme={data[theme]} key={data[theme].id} />   )) }

Now let’s turn to the markup for a ThemeCard. Yours may look different, but notice how we extract its own color and font properties, then apply them:

const ThemeCard = props => {   return(     <Wrapper        style={{backgroundColor: `$ {data[_.camelCase(props.theme.name)].colors.body}`, color: `$ {data[_.camelCase(props.theme.name)].colors.text}`, fontFamily: `$ {data[_.camelCase(props.theme.name)].font}`}}>       <span>Click on the button to set this theme</span>       <ThemedButton         onClick={ (theme) => themeSwitcher(props.theme) }         style={{backgroundColor: `$ {data[_.camelCase(props.theme.name)].colors.button.background}`, color: `$ {data[_.camelCase(props.theme.name)].colors.button.text}`, fontFamily: `$ {data[_.camelCase(props.theme.name)].font}`}}>         {props.theme.name}       </ThemedButton>     </Wrapper>   ) }

Next up, let’s create a file called ThemeSelector.js in our the src folder. Copy the content from here and drop it into the file to establish our theme switcher, which we need to import in App.js:

import ThemeSelector from './ThemeSelector';

Now we can use it inside the Container component:

<Container style={{fontFamily: selectedTheme.font}}>   // same as before   <ThemeSelector setter={ setSelectedTheme } /> </Container>

Let’s refresh the browser now and see how switching themes works.

An animated screenshot showing the theme changing when it is selected from the list of theme card options.

The fun part is, you can add as many as themes in the schema.json file to load them in the UI and switch. Check out this schema.json file for some more themes. Please note, we are also saving the applied theme information in localStorage, so the selection will be retained when you reopen the app next time.

Selected theme stored in the Local Storage.

Customize a theme

Maybe your users like some aspects of one theme and some aspects of another. Why make them choose between them when they can give them the ability to define the theme props themselves! We can create a simple user interface that allows users to select the appearance options they want, and even save their preferences.

Animated screenshot showing a modal opening with a list of theme options to customize the appearance, including the them name, background color, text color, button text color, link color, and font.

We will not cover the theme creation code explanation in details but, it should be easy by following the code in the GitHub Repo. The main source file is CreateThemeContent.js and it is used by App.js. We create the new theme object by gathering the value from each input element change event and add the object to the collection of theme objects. That’s all.

Before we end…

Thank you for reading! I hope you find what we covered here useful for something you’re working on. Theming systems are fun! In fact, CSS custom properties are making that more and more a thing. For example, check out this approach for color from Dieter Raber and this roundup from Chris. There’s also this setup from Michelle Barker that relies on custom properties used with Tailwind CSS. Here’s yet another way from Andrés Galente.

Where all of these are great example for creating themes, I hope this article helps take that concept to the next level by storing properties, easily switching between themes, giving users a way to customize a theme, and saving those preferences.

Let’s connect! You can DM me on Twitter with comments, or feel free to follow.


The post Theming and Theme Switching with React and styled-components appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, , , ,
[Top]

DRY State Switching With CSS Variables: Fallbacks and Invalid Values

This is the second post in a two-part series that looks into the way CSS variables can be used to make the code for complex layouts and interactions less difficult to write and a lot easier to maintain. The first installment walks through various use cases where this technique applies. This post covers the use of fallbacks and invalid values to extend the technique to non-numeric values.

The strategy of using CSS Variables to drive the switching of layouts and interactions that we covered in the first post in this series comes with one major caveat: it only works with numeric values — lengths, percentages, angles, durations, frequencies, unit-less number values and so on. As a result, it can be really frustrating to know that you’re able to switch the computed values of more than ten properties with a single CSS variable, but then you need to explicitly switch the non-numeric values of properties like flex-direction or text-align from row to column or from left to right or the other way around.

One example would be the one below, where the text-align property depends on parity and the flex-direction depends on whether we are viewing the front end in the wide screen scenario or not.

Screenshot collage. On the left, we have the wide screen scenario, with four paragraphs as the four horizontal, offset based on parity slices of a disc. The slice numbering position is either to the right or left of the actual text content, depending on parity. The text alignment also depends on parity. In the middle, we have the normal screen case. The paragraphs are now full width rectangular elements. On the right, we have the narrow screen case. The paragraph numbering is always above the actual text content in this case.
Screenshot collage.

I complained about this and got a very interesting suggestion in return that makes use of CSS variable fallbacks and invalid values. It was interesting and gives us something new to work with, so let’s start with a short recap of what these are and go from there!

Fallback values

The fallback value of a CSS variable is the second and optional argument of the var() function. For example, let’s consider we have some .box elements whose background is set to a variable of --c:

.box { background: var(--c, #ccc) }

If we haven’t explicitly specified a value for the --c variable elsewhere, then the fallback value #ccc is used.

Now let’s say some of these boxes have a class of .special. Here, we can specify --c as being some kind of orange:

.special { --c: #f90 }

This way, the boxes with this .special class have an orange background, while the others use the light grey fallback.

See the Pen by thebabydino (@thebabydino) on CodePen.

There are a few things to note here.

First off, the fallback can be another CSS variable, which can have a CSS variable fallback itself and… we can fall down a really deep rabbit hole this way!

background: var(--c, var(--c0, var(--c1, var(--c2, var(--c3, var(--c4, #ccc))))))

Secondly, a comma separated list is a perfectly valid fallback value. In fact, everything specified after the first comma inside the var() function constitutes the fallback value, as seen in the example below:

background: linear-gradient(90deg, var(--stop-list, #ccc, #f90))

See the Pen by thebabydino (@thebabydino) on CodePen.

And last, but certainly not least, we can have different fallback values for the same variable used in different places, as illustrated by this example:

$ highlight: #f90;  a {   border: solid 2px var(--c, #{rgba($ highlight, 0)})   color: var(--c, #ccc);      &:hover, &:focus { --c: #{$ highlight} } }

See the Pen by thebabydino (@thebabydino) on CodePen.

Invalid values

First off, I want to clarify what I mean by this. “Invalid values” is shorter and easier to remember, but what it really refers to any value that makes a declaration invalid at computed value time.

For example, consider the following piece of code:

--c: 1em; background: var(--c)

1em is a valid length value, but this is not a valid value for the background-color property, so here this property will take its initial value (which is transparent) instead.

Putting it all together

Let’s say we have a bunch of paragraphs where we change the lightness of the color value to switch between black and white based on parity (as explained in the previous post in this series):

p {   --i: 0;   /* for --i: 0 (odd), the lightness is 0*100% = 0% (black)    * for --i: 1 (even), the lightness is 1*100% = 100% (white)* /   color: hsl(0, 0%, calc(var(--i)*100%));    &:nth-child(2n) { --i: 1 } }

We also want the odd paragraphs to be right-aligned, while keeping the even ones left-aligned. In order to achieve this, we introduce a --parity variable which we don’t set explicitly in the general case — only for even items. What we do set in the general case is our previous variable, --i. We set it to the value of --parity with a fallback of 0:

p {   --i: var(--parity, 0);   color: hsl(0, 0%, calc(var(--i)*100%));    &:nth-child(2n) { --parity: 1 } }

So far, this achieves exactly the same as the previous version of our code. However, if we take advantage of the fact that, we can use different fallback values in different places for the same variable, then we can also set text-align to the value of --parity using a fallback of… right!

text-align: var(--parity, right)

In the general case, where we’re not setting --parity explicitly; text-align uses the fallback right, which is a valid value, so we have right alignment. For the even items however, we’re setting --parity explicitly to 1, which is not a valid value for text-align. That means text-align reverts to its initial value, which is left.

See the Pen by thebabydino (@thebabydino) on CodePen.

Now we have right alignment for the odd items and left alignment for the even items while still putting a single CSS variable to use!

Dissecting a more complex example

Let’s consider we want to get the result below:

Screenshot. Shows a bunch of numbered cards. Odd ones have the numbering on the left, while even ones have it on the right. Odd ones are right-aligned, while even ones are left-aligned. Odd ones are shifted a bit to the right and have a bit of a clockwise rotation, while even ones are shifted and rotated by the same amounts, but in the opposite directions. All have a grey to orange gradient background, but for the odd ones, this gradient goes from left to right, while for the even ones it goes from right to left.
Numbered cards where even cards have symmetrical styles with respect to odd cards.

We create these cards with a paragraph element <p> for each one. We switch their box-sizing to border-box, then give them a width, a max-width, a padding and a margin. We also change the default font.

See the Pen by thebabydino (@thebabydino) on CodePen.

We’ve also added a dummy outline just to see the boundaries of these elements.

Next, let’s add the numbering using CSS counters and a :before pseudo-element:

p {   /* same code as before */   counter-increment: c;      &:before { content: counter(c, decimal-leading-zero) } }

See the Pen by thebabydino (@thebabydino) on CodePen.

Now, we’ll give our paragraphs a flex layout and increase the size of the numbering:

p {   /* same code as before */   display: flex;   align-items: center;      &:before {     font-size: 2em;     content: counter(c, decimal-leading-zero);   } }

See the Pen by thebabydino (@thebabydino) on CodePen.

Now comes the interesting part!

We set a switch --i that changes value with the parity — it’s 0 for the odd items and 1 for the even ones.

p {   /* same code as before */   --i: 0;      &:nth-child(2n) { --i: 1 } }

Next, we want the numbering to be on the left for the odd items and on the right for the even ones. We achieve this via the order property. The initial value for this property is 0, for both the :before pseudo-element and the paragraph’s text content. If we set this order property to 1 for the numbering (the :before pseudo-element) of the even elements, then this moves the numbering after the content.

p {   /* same code as before */   --i: 0;      &:before {     /* same code as before */     /* we don't really need to set order explicitly as 0 is the initial value */     order: 0;   }      &:nth-child(2n) {     --i: 1;          &:before { order: 1 }   } }

You may notice that, in this case, the order value is the same as the switch --i value, so in order to simplify things, we set the order to the switch value.

p {   /* same code as before */   --i: 0;      &:before {     /* same code as before */     order: var(--i)   }      &:nth-child(2n) { --i: 1 } }

See the Pen by thebabydino (@thebabydino) on CodePen.

Now we want a bit of spacing (let’s say $ gap) in between the numbers and the paragraph text. This can be achieved with a lateral margin on the :before.

For the odd items, the item numbers are on the left, so we need a non-zero margin-right. For the even items, the item numbers are on the right, so we need a non-zero margin-left.

When the parity switch value is 0 for the odd items, the left margin is 0 = 0*$ gap, while the right margin is $ gap = 1*$ gap = (1 - 0)*$ gap.

Similarly for the even items, when the parity switch value is 1, the left margin is $ gap = 1*$ gap, while the right margin is 0 = 0*$ gap = (1 - 1)*$ gap.

The result in both cases is that margin-left is the parity switch value times the margin value ($ gap), while margin-right is 1 minus the parity switch value, all multiplied with the margin value.

$ gap: .75em;  p {   /* same code as before */   --i: 0;      &:before {     /* same code as before */     margin:        0                            /* top */       calc((1 - var(--i))*#{$ gap}) /* right */       0                            /* bottom */       calc(var(--i)*#{$ gap})       /* left */;   }      &:nth-child(2n) { --i: 1 } }

If we use the complementary value (1 - var(--i)) in more than one place, then it’s probably best to set it to another CSS variable --j.

$ gap: .75em;  p {   /* same code as before */   --i: 0;   --j: calc(1 - var(--i));      &:before {     /* same code as before */     margin:        0                      /* top */       calc(var(--j)*#{$ gap}) /* right */       0                      /* bottom */       calc(var(--i)*#{$ gap}) /* left */;   }      &:nth-child(2n) { --i: 1 } }

See the Pen by thebabydino (@thebabydino) on CodePen.

Next, we want to give these items a proper background. This is a grey to orange gradient, going from left to right (or along a 90deg angle) in the case of odd items (parity switch --i: 0) and from right to left (at a -90deg angle) in the case of even items (parity switch --i: 1).

This means the absolute value of the gradient angle is the same (90deg), only the sign is different — it’s +1 for the odd items (--i: 0) and -1 for the even items (--i: 1).

In order to switch the sign, we use the approach we covered in the first post:

/*  * for --i: 0, we have 1 - 2*0 = 1 - 0 = +1  * for --i: 1, we have 1 - 2*1 = 1 - 2 = -1  */ --s: calc(1 - 2*var(--i))

This way, our code becomes:

p {   /* same code as before */   --i: 0;   --s: calc(1 - 2*var(--i));   background: linear-gradient(calc(var(--s)*90deg), #ccc, #f90);      &:nth-child(2n) { --i: 1 } }

We can also remove the dummy outline since we don’t need it at this point:

See the Pen by thebabydino (@thebabydino) on CodePen.

Next, we do something similar for the transform property.

The odd items are translated a bit to the right (in the positive direction of the x axis) and rotated a bit in the clockwise (positive) direction, while the even items are translated a bit to the left (in the negative direction of the x axis) and rotated a bit in the other (negative) direction.

The translation and rotation amounts are the same; only the signs differ.

For the odd items, the transform chain is:

translate(10%) rotate(5deg)

While for the even items, we have:

translate(-10%) rotate(-5deg)

Using our sign --s variable, the unified code is:

p {   /* same code as before */   --i: 0;   --s: calc(1 - 2*var(--i));   transform: translate(calc(var(--s)*10%))               rotate(calc(var(--s)*5deg));      &:nth-child(2n) { --i: 1 } }

This is now starting to look like something!

See the Pen by thebabydino (@thebabydino) on CodePen.

The next step is to round the card corners. For the odd cards, we want the corners on the left side to be rounded to a radius of half the height. For the even items, we want the corners on the right side to be rounded to the same radius.

Given we don’t know the heights of our cards, we just use a ridiculously large value, say something like 50vh, which gets scaled down to fit due to the way border-radius works. In our case, this means scaled down to whichever is smaller between half the item height (since going vertically has both a top and bottom rounded corner on the same side) and the full item width (since going horizontally has one rounded corner; either on the left or on the right, but not on both the right and the left).

This means we want the corners on the left to have this radius ($ r: 50vh) for odd items (--i: 0) and the ones on the right to have the same radius for even items (--i: 1). As a result, we do something pretty similar to the numbering margin case:

$ r: 50vh;  p {   /* same code as before */   --i: 0;   --j: calc(1 - var(--i));   --r0: calc(var(--j)*#{$ r});   --r1: calc(var(--i)*#{$ r});   /* clockwise from the top left */   border-radius: var(--r0) /* top left */                  var(--r1) /* top right */                  var(--r1) /* bottom right */                  var(--r0) /* bottom left */;      &:nth-child(2n) { --i: 1 } }

See the Pen by thebabydino (@thebabydino) on CodePen.

Now comes the truly interesting part — text alignment! We want the text in the odd items to be aligned right, while the text in the even items is aligned left. The only problem is that text-align doesn’t take a number value so, no addition or multiplication tricks can help us here.

What can help is combining the use of fallback and invalid values for CSS variables. To do this, we introduce another parity variable --p and it’s this variable that we actually set to 1 for even items. Unlike --i before, we never set --p explicitly for the general case as we want different fallback values of this variable to be used for different properties.

As for --i, we set it to --p with a fallback value of 0. This fallback value of 0 is the value that actually gets used in the general case, since we never explicitly set --p there. For the even case, where we explicitly set --p to 1, --i becomes 1 as well.

At the same time, we set the text-align property to --p with a fallback value of right in the general case. In the even case, where we have --p explicitly set to 1, the text-align value becomes invalid (because we have set text-align to the value of --p and --p is now 1, which is not a valid value for text-align), so the text reverts to being aligned to the left.

p {   /* same code as before */   --i: var(--p, 0);   text-align: var(--p, right);      &:nth-child(2n) { --p: 1 } }

This gives us the result we’ve been after:

See the Pen by thebabydino (@thebabydino) on CodePen.

Handling responsiveness

While our cards example looks great on wider screens, the same can’t be said when shrink things down.

Screenshot collage. Since the width of the cards depends on the viewport width, the viewport may get too narrow to allow for displaying the numbering and the paragraph text side by side and the right one of the two overflows in this case.
The wide screen result (left) vs. the narrow screen result (right)

In order to fix this, we introduce two more custom properties, --wide and --k to switch between the wide and narrow cases. We set --k to --wide with a fallback value of 0 in the general case and then set --wide to 1 if the viewport width is anything 340px and up.

p {   /* same code as before */   --k: var(--wide, 0);      @media (min-width: 340px) { --wide: 1 } }

Since we only want our items to be transformed and have rounded corners in the wide case, we multiply the translation, rotation and radius values by --k (which is 0, unless the viewport is wide, which switches its value to 1).

p {   /* same code as before */   --k: var(--wide, 0);   --r0: calc(var(--k)*var(--j)*#{$ r});   --r1: calc(var(--k)*var(--i)*#{$ r});   border-radius: var(--r0) /* top left */                  var(--r1) /* top right */                  var(--r1) /* bottom right */                  var(--r0) /* bottom left */;   transform: translate(calc(var(--k)*var(--s)*10%))               rotate(calc(var(--k)*var(--s)*5deg));    @media (min-width: 340px) { --wide: 1 } }

This is slightly better, but our content still overflows in narrow viewports. We can fix this by only placing the numbering (the :before pseudo-element) on the left or right side only in the wide case then moving it above the card in the narrow case.

In order to do this, we multiply both its order and its lateral margin values by --k (which is 1 in the wide case and 0 otherwise).

We also set flex-direction to --wide with a fallback value of column.

This means the flex-direction value is column in the general case (since we haven’t set --wide explicitly elsewhere). However, if the viewport is wide (min-width: 340px), then our --wide variable gets set to 1. But 1 is an invalid value for flex-direction, so this property reverts back to its initial value of row.

p {   /* same code as before */   --k: var(--wide, 0);   flex-direction: var(--wide, column);      &:before {     /* same code as before */     order: calc(var(--k)*var(--i));     margin:        0                               /* top */       calc(var(--k)*var(--j)*#{$ gap}) /* right */       0                               /* bottom */       calc(var(--k)*var(--i)*#{$ gap}) /* left */;   }      @media (min-width: 340px) { --wide: 1 } }

Coupled with setting a min-width of 160px on the body, we’ve now eliminated the overflow issue:

Responsive cards, no overflow (live demo).

One more thing we can do is tweak the font-size so that it also depends on --k:

p {   /* same code as before */   --k: var(--wide, 0);   font: 900 calc(var(--k)*.5em + .75em) cursive;    @media (min-width: 340px) { --wide: 1 } }

And that’s it, our demo is now nicely responsive!

Responsive cards, font smaller for narrow screens and with no overflow (live demo).

A few more quick examples!

Let’s look at a few more demos that use the same technique, but quickly without building them from scratch. We’ll merely go through the basic ideas behind them.

Disc slices

Sliced disc (live demo).

Just like the cards example we completed together, we can use a :before pseudo-element for the numbering and a flex layout on the paragraphs. The sliced disc effect is achieved using clip-path.

The paragraph elements themselves — the horizontal offsets, the position and intensity of the radial-gradient() creating the shadow effect, the direction of the linear-gradient() and the saturation of its stops, the color and the text alignment — all depend on the --parity variable.

p {   /* other styles not relevant here */   --p: var(--parity, 1);   --q: calc(1 - var(--p));   --s: calc(1 - 2*var(--p)); /* sign depending on parity */   transform: translate((calc(var(--i)*var(--s)*#{-$ x})));   background:      radial-gradient(at calc(var(--q)*100%) 0,        rgba(0, 0, 0, calc(.5 + var(--p)*.5)), transparent 63%)        calc(var(--q)*100%) 0/ 65% 65% no-repeat,      linear-gradient(calc(var(--s)*-90deg),        hsl(23, calc(var(--q)*98%), calc(27% + var(--q)*20%)),        hsl(44, calc(var(--q)*92%), 52%));   color: HSL(0, 0%, calc(var(--p)*100%));   text-align: var(--parity, right); 	   &:nth-child(odd) { --parity: 0 } }

For the numbering (the :before pseudo-elements of the paragraphs), we have that both the margin and the order depend on the --parity in the exact same way as the cards example.

If the viewport width is smaller than the disc diameter $ d plus twice the horizontal slice offset in absolute value $ x, then we’re not in the --wide case anymore. This affects the width, padding and margin of our paragraphs, as well as their horizontal offset and their shape (because we don’t clip them to get the sliced disc effect at that point).

body {   /* other styles not relevant here */   --i: var(--wide, 1);   --j: calc(1 - var(--i)); 	   @media (max-width: $ d + 2*$ x) { --wide: 0 } }  p {   /* other styles not relevant here */   margin: calc(var(--j)*.25em) 0;   padding:      calc(var(--i)*#{.5*$ r}/var(--n) + var(--j)*5vw) /* vertical */     calc(var(--i)*#{.5*$ r} + var(--j)*2vw) /* horizontal */;   width: calc(var(--i)*#{$ d} /* wide */ +                var(--j)*100% /* not wide */);   transform: translate((calc(var(--i)*var(--s)*#{-$ x})));   clip-path:      var(--wide,                 /* fallback, used in the wide case only */       circle($ r at 50% calc((.5*var(--n) - var(--idx))*#{$ d}/var(--n)))); }

We’re in the narrow case below 270px and have a flex-direction of column on our paragraphs. We also zero out both the lateral margins and the order for the numbering.

body {   /* other styles not relevant here */   --k: calc(1 - var(--narr, 1)); 	   @media (min-width: 270px) { --narr: 0 } }  p {   /* other styles not relevant here */   flex-direction: var(--narr, column);    &:before {     /* other styles not relevant here */     margin:        0                             /* top */       calc(var(--k)*var(--q)*.25em) /* right */       0                             /* bottom */       calc(var(--k)*var(--p)*.25em) /* left */;     order: calc(var(--k)*var(--p));   } }

Four-step infographic

Screenshot collage. On the left, there's the wide screen scenario. In the middle, there's the normal screen scenario. On the right, there's the narrow screen scenario.
A four-step infographic (live demo).

This works pretty much the same as the previous two examples. We have a flex layout on our paragraphs using a column direction in the narrow case. We also have a smaller font-size in that same case:

body {   /* other styles not relevant here */   --k: var(--narr, 1);      @media (min-width: 400px) { --narr: 0 } }  p {   /* other styles not relevant here */   flex-direction: var(--narr, column);   font-size: calc((1.25 - .375*var(--k))*1em); }

The parity determines each paragraph’s text alignment, which lateral border gets a non-zero value, and the position and direction of the border gradient. Both the parity and whether we’re in the wide screen case or not determine the lateral margins and paddings.

body {   /* other styles not relevant here */   --i: var(--wide, 1);   --j: calc(1 - var(--i));      @media (max-width: $ bar-w + .5*$ bar-h) { --wide: 0 } }  p {   /* other styles not relevant here */   margin:      .5em                                 /* top */     calc(var(--i)*var(--p)*#{.5*$ bar-h}) /* right */     0                                    /* bottom */     calc(var(--i)*var(--q)*#{.5*$ bar-h}) /* left */;   border-width:      0                        /* top */     calc(var(--q)*#{$ bar-b}) /* right */     0                        /* bottom */     calc(var(--p)*#{$ bar-b}) /* left */;   padding:      $ bar-p                                         /* top */     calc((var(--j) + var(--i)*var(--q))*#{$ bar-p}) /* right */     $ bar-p                                         /* bottom */     calc((var(--j) + var(--i)*var(--p))*#{$ bar-p}) /* left */;   background:      linear-gradient(#fcfcfc, gainsboro) padding-box,      linear-gradient(calc(var(--s)*90deg), var(--c0), var(--c1))        calc(var(--q)*100%) /* background-position */ /        #{$ bar-b} 100% /* background-size */;   text-align: var(--parity, right); }

The icon is created using the :before pseudo-element, and its order depends on the parity, but only if we’re not in the narrow screen scenario — in which case it’s always before the actual text content of the paragraph. Its lateral margin depends both on the parity and whether we are in the wide screen case or not. The big-valued component that positions it half out of its parent paragraph is only present in the wide screen case. The font-size also depends on whether we’re in the narrow screen case or not (and this influences its em dimensions and padding).

order: calc((1 - var(--k))*var(--p)); margin:    0                                                          /* top */   calc(var(--i)*var(--p)*#{-.5*$ ico-d} + var(--q)*#{$ bar-p}) /* right */   0                                                          /* bottom */   calc(var(--i)*var(--q)*#{-.5*$ ico-d} + var(--p)*#{$ bar-p}) /* left */; font-size: calc(#{$ ico-s}/(1 + var(--k)));

The ring is created using an absolutely positioned :after pseudo-element (and its placement depends on parity), but only for the wide screen case.

content: var(--wide, '');

The two-dimension case

Screenshot collage. On the left, we have the wide screen scenario. Each article is laid out as a 2x2 grid, with the numbering occupying an entire column, either on the right for odd items or on the left for even items. The heading and the actual text occupy the other column. In the middle, we have the normal screen case. Here, we also have a 2x2 grid, but the numbering occupies only the top row on the same column as before, while the actual text content now spans both columns on the second row. On the right, we have the narrow screen case. In this case, we don't have a grid anymore, the numbering, the heading and the actual text are one under the other for each article.
Screenshot collage (live demo, no Edge support due to CSS variable and calc() bugs).

Here we have a bunch of article elements, each containing a heading. Let’s check out the most interesting aspects of how this responsive layout works!

On each article, we have a two-dimensional layout (grid) — but only if we’re not in the narrow screen scenario (--narr: 1), in which case we fall back on the normal document flow with the numbering created using a :before pseudo-element, followed by the heading, followed by the actual text. In this situation, we also add vertical padding on the heading since we don’t have the grid gaps anymore and we don’t want things to get too crammed.

html {   --k: var(--narr, 0); 	   @media (max-width: 250px) { --narr: 1 } }  article {   /* other styles irrelevant here */   display: var(--narr, grid); }  h3 {   /* other styles irrelevant here */   padding: calc(var(--k)*#{$ hd3-p-narr}) 0; }

For the grid, we create two columns of widths depending both on parity and on whether we’re in the wide screen scenario. We make the numbering (the :before pseudo-element) span two rows in the wide screen case, either on the second column or the first, depending on the parity. If we’re not in the wide screen case, then the paragraph spans both columns on the second row.

We set the grid-auto-flow to column dense in the wide screen scenario, letting it revert to the initial value of row otherwise. Since our article elements are wider than the combined widths of the columns and the column gap between them, we use place-content to position the actual grid columns inside at the right or left end depending on parity.

Finally, we place the heading at the end or start of the column, depending on parity, and we as well as the paragraph’s text alignment if we’re in the wide screen scenario.

$ col-1-wide: calc(var(--q)*#{$ col-a-wide} + var(--p)*#{$ col-b-wide}); $ col-2-wide: calc(var(--p)*#{$ col-a-wide} + var(--q)*#{$ col-b-wide});  $ col-1-norm: calc(var(--q)*#{$ col-a-norm} + var(--p)*#{$ col-b-norm}); $ col-2-norm: calc(var(--p)*#{$ col-a-norm} + var(--q)*#{$ col-b-norm});  $ col-1: calc(var(--i)*#{$ col-1-wide} + var(--j)*#{$ col-1-norm}); $ col-2: calc(var(--i)*#{$ col-2-wide} + var(--j)*#{$ col-2-norm});  html {   --i: var(--wide, 1);   --j: calc(1 - var(--i)); 	   @media (max-width: $ art-w-wide) { --wide: 0 } }  article {   /* other styles irrelevant here */   --p: var(--parity, 1);   --q: calc(1 - var(--p));   grid-template-columns: #{$ col-1} #{$ col-2};   grid-auto-flow: var(--wide, dense column);   place-content: var(--parity, center end);      &:before {     /* other styles irrelevant here */     grid-row: 1/ span calc(1 + var(--i));     grid-column: calc(1 + var(--p))/ span 1;   }      &:nth-child(odd) { --parity: 0 } }  h3 {   /* other styles irrelevant here */   justify-self: var(--parity, self-end); }  p {   grid-column-end: span calc(1 + var(--j));   text-align: var(--wide, var(--parity, right)); }

We also have numerical values such as grid gaps, border radii, paddings, font-sizes, gradient directions, rotation and translation directions depending on the parity and/or whether we’re in the wide screen scenario or not.

Even more examples!

If you want more of this, I’ve created an entire collection of similar responsive demos for you to enjoy!

Screenshot of collection page on CodePen, showing the six most recent demos added.
Collection of responsive demos.

The post DRY State Switching With CSS Variables: Fallbacks and Invalid Values appeared first on CSS-Tricks.

CSS-Tricks

, , , , ,
[Top]

DRY Switching with CSS Variables: The Difference of One Declaration

This is the first post of a two-part series that looks into the way CSS variables can be used to make the code for complex layouts and interactions less difficult to write and a lot easier to maintain. This first installment walks through various use cases where this technique applies. The second post (coming tomorrow!) will cover the use of fallbacks and invalid values to extend the technique to non-numeric values.

What if I told you a single CSS declaration makes the difference in the following image between the wide screen case (left) and the second one (right)? And what if I told you a single CSS declaration makes the difference between the odd and even items in the wide screen case?

On the left, a screenshot of the wide screen scenario. Each item is limited in width and its components are arranged on a 2D 2x2 grid, with the first level heading occupying an entire column, either the one on the right (for odd items) or the one on the left (for even items). The second level heading and the actual text occupy the other column. The shape of the first level heading also varies depending on the parity — it has the top left and the bottom right corners rounded for the odd items and the other two corners rounded for the even items. On the right, a screenshot of the narrower scenario. Each item spans the full viewport width and its components are placed vertically, one under another — first level heading, second level heading below and, finally, the actual text.
Screenshot collage.

Or that a single CSS declaration makes the difference between the collapsed and expanded cases below?

Animated gif. Shows a green button with a magnifier icon. Clicking this button makes it slide right and its background to turn red while a text search field slides out of it to the left and the magnifier morphs into a close (crossmark) icon.
Expanding search.

How is that even possible?

Well, as you may have guessed from the title, it’s all in the power of CSS variables.

There are already plenty of articles out there on what CSS variables are and how to get started with them, so we won’t be getting into that here.

Instead, we’ll dive straight into why CSS variables are useful for achieving these cases and others, then we’ll move on to a detailed explanation of the how for various cases. We’ll code an actual example from scratch, step by step, and, finally, you’ll be getting some eye candy in the form of a few more demos that use the same technique.

So let’s get started!

Why CSS variables are useful

For me, the best thing about CSS variables is that they’ve opened the door for styling things in a logical, mathematical and effortless way.

One example of this is the CSS variable version of the yin and yang loader I coded last year. For this version, we create the two halves with the two pseudo-elements of the loader element.

Animated gif. The yin and yang symbol is rotating while its two lobes alternate increasing and decreasing in size - whenever one is increasing, it squishes the other one down.
Rotating ☯ symbol, with its two lobes increasing and decreasing in size.

We use the same background, border-color, transform-origin and animation-delay values for the two halves. These values all depend on a switch variable --i that’s initially set to 0 on both halves (the pseudo-elements), but then we change it to 1 for the second half (the :after pseudo-element), thus dynamically modifying the computed values of all these properties.

Without CSS variables, we’d have to set all these properties (border-color, transform-origin, background, animation-delay) again on the :after pseudo-element and risk making some typo or even forgetting to set some of them.

How switching works in the general case

Switching between a zero and a non-zero value

In the particular case of the yin and yang loader, all the properties we change between the two halves (pseudo-elements) go from a zero value for one state of the switch and a non-zero value for the other state.

If we want our value to be zero when the switch is off (--i: 0) and non-zero when the switch is on (--i: 1), then we multiply it with the switch value (var(--i)). This way, if our non-zero value should be, let’s say an angular value of 30deg, we have:

  • when the switch is off (--i: 0), calc(var(--i)*30deg) computes to 0*30deg = 0deg
  • when the switch is on (--i: 1), calc(var(--i)*30deg) computes to 1*30deg = 30deg

However, if we want our value to be non-zero when the switch is off (--i: 0) and zero when the switch is on (--i: 1), then we multiply it with the complementary of the switch value (1 - var(--i)). This way, for the same non-zero angular value of 30deg, we have:

  • when the switch is off (--i: 0), calc((1 - var(--i))*30deg) computes to (1 - 0)*30deg = 1*30deg = 30deg
  • when the switch is on (--i: 1), calc((1 - var(--i))*30deg) computes to (1 - 1)*30deg = 0*30deg = 0deg

You can see this concept illustrated below:

Animated gif. Shows how changing the switch value from 0 to 1 changes the rotation of two boxes. The first box is rotated to 30deg when the switch is off (its value is 0) and not rotated or rotated to 0deg when the switch is on (its value is 1). This means we have a rotation value of calc((1 - var(--i))*30deg), where --i is the switch value. The second box is not rotated or rotated to 0deg when the switch is off (its value is 0) and rotated to 30deg when the switch is on (its value is 1). This means we have a rotation value of calc(var(--i)*30deg), with --i being the switch value.
Switching between a zero and a non-zero value (live demo, no Edge support due to calc() not working for angle values)

For the particular case of the loader, we use HSL values for border-color and background-color. HSL stands for hue, saturation, lightness and can be best represented visually with the help of a bicone (which is made up of two cones with the bases glued together).

Two cones with their bases glued together in the middle, one vertex pointing down and one up. The hue is cyclic, distributed around the central (vertical) axis of the bicone. The saturation axis goes horizontally from the central axis towards the surface of the bicone - it's 0% right on the axis and 100% right on the surface. The lightness axis goes vertically from the black vertex to the white one - it's 0% at the black vertex and 100% at the white vertex.
HSL bicone.

The hues go around the bicone, being equivalent to 360° to give us a red in both cases.

Shows the red being at 0° (which is equivalent to 360° since the hue is cyclic), the yellow at 60°, the lime at 120°, the cyan at 180°, the blue at 240° and the magenta at 300°.
Hue wheel.

The saturation goes from 0% on the vertical axis of the bicone to 100% on the bicone surface. When the saturation is 0% (on the vertical axis of the bicone), the hue doesn’t matter anymore; we get the exact same grey for all hues in the same horizontal plane.

The “same horizontal plane” means having the same lightness, which increases along the vertical bicone axis, going from 0% at the black bicone vertex to 100% at the white bicone vertex. When the lightness is either 0% or 100%, neither the hue nor the saturation matter anymore – we always get black for a lightness value of 0% and white for a lightness value of 100%.

Since we only need black and white for our ☯ symbol, the hue and saturation are irrelevant, so we zero them and then switch between black and white by switching the lightness between 0% and 100%.

.yin-yang {   /* other styles that are irrelevant here */      &:before, &:after {     /* other styles that are irrelevant here */     --i: 0;      /* lightness of border-color when       * --i: 0 is (1 - 0)*100% = 1*100% = 100% (white)      * --i: 1 is (1 - 1)*100% = 0*100% =   0% (black) */     border: solid $ d/6 hsl(0, 0%, calc((1 - var(--i))*100%));      /* x coordinate of transform-origin when       * --i: 0 is 0*100% =   0% (left)       * --i: 1 is 1*100% = 100% (right) */     transform-origin: calc(var(--i)*100%) 50%;      /* lightness of background-color when       * --i: 0 is 0*100% =   0% (black)       * --i: 1 is 1*100% = 100% (white) */     background: hsl(0, 0%, calc(var(--i)*100%));      /* animation-delay when      * --i: 0 is 0*-$ t = 0s       * --i: 1 is 1*-$ t = -$ t */     animation: s $ t ease-in-out calc(var(--i)*#{-$ t}) infinite alternate;   } 	   &:after { --i: 1 } }

Note that this approach doesn’t work in Edge due to the fact that Edge doesn’t support calc() values for animation-delay.

But what if we want to have a non-zero value when the switch is off (--i: 0) and another different non-zero value when the switch is on (--i: 1)?

Switching between two non-zero values

Let’s say we want an element to have a grey background (#ccc) when the switch is off (--i: 0) and an orange background (#f90) when the switch is on (--i: 1).

The first thing we do is switch from hex to a more manageable format such as rgb() or hsl().

We could do this manually either by using a tool such as Lea Verou’s CSS Colors or via DevTools. If we have a background set on an element we can cycle through formats by keeping the Shift key pressed while clicking on the square (or circle) in front of the value in DevTools. This works in both Chrome and Firefox, though it doesn’t appear to work in Edge.

Animated gif. Shows how to cycle through formats (hex/ RGB/ HSL) via DevTools. In both Chrome and Firefox, we do this by keeping the Shift key pressed and clicking the square or circle in front of the <color> value.”/><figcaption>Changing the format from DevTools.</figcaption></figure>
<p>Even better, if we’re using Sass, we can extract the components with <a href=red()/ green()/ blue() or hue()/ saturation()/ lightness() functions.

While rgb() may be the better known format, I tend to prefer hsl() because I find it more intuitive and it’s easier for me to get an idea about what to expect visually just by looking at the code.

So we extract the three components of the hsl() equivalents of our two values ($ c0: #ccc when the switch is off and $ c1: #f90 when the switch is on) using these functions:

$ c0: #ccc; $ c1: #f90;  $ h0: round(hue($ c0)/1deg); $ s0: round(saturation($ c0)); $ l0: round(lightness($ c0));  $ h1: round(hue($ c1)/1deg); $ s1: round(saturation($ c1)); $ l1: round(lightness($ c1))

Note that we’ve rounded the results of the hue(), saturation() and lightness() functions as they may return a lot of decimals and we want to keep our generated code clean. We’ve also divided the result of the hue() function by 1deg, as the returned value is a degree value in this case and Edge only supports unit-less values inside the CSS hsl() function. Normally, when using Sass, we can have degree values, not just unit-less ones for the hue inside the hsl() function because Sass treats it as the Sass hsl() function, which gets compiled into a CSS hsl() function with a unit-less hue. But here, we have a dynamic CSS variable inside, so Sass treats this function as the CSS hsl() function that doesn’t get compiled into anything else, so, if the hue has a unit, this doesn’t get removed from the generated CSS.

Now we have that:

  • if the switch is off (--i: 0), our background is
    hsl($ h0, $ s0, $ l0)
  • if the switch is on (--i: 1), our background is
    hsl($ h1, $ s1, $ l1)

We can write our two backgrounds as:

  • if the switch is off (--i: 0),
    hsl(1*$ h0 + 0*$ h1, 1*$ s0 + 0*$ s1, 1*$ l0 + 1*$ l1)
  • if the switch is on (--i: 1),
    hsl(0*$ h0 + 1*$ h1, 0*$ s0 + 1*$ s1, 0*$ l0 + 1*$ l1)

Using the switch variable --i, we can unify the two cases:

--j: calc(1 - var(--i)); background: hsl(calc(var(--j)*#{$ h0} + var(--i)*#{$ h1}),                  calc(var(--j)*#{$ s0} + var(--i)*#{$ s1}),                  calc(var(--j)*#{$ l0} + var(--i)*#{$ l1}))

Here, we’ve denoted by --j the complementary value of --i (when --i is 0, --j is 1 and when --i is 1, --j is 0).

Animated gif. Shows how changing the switch value from 0 to 1 changes the background of a box. The background is grey (of hue $ h0, saturation $ s0 and lightness $ l0) when the switch is turned off (its value is zero) and orange (of hue $ h1, saturation $ s1 and lightness $ l1) when the switch is turned on (its value is 1). This means we have a hue value of calc(var(--j)*#{$ h0} + var(--i)*#{$ h1}), a saturation value of calc(var(--j)*#{$ s0} + var(--i)*#{$ s1}) and a lightness value of calc(var(--j)*#{$ l0} + var(--i)*#{$ l1})), where --i is the switch variable.
Switching between two backgrounds (live demo)

The formula above works for switching in between any two HSL values. However, in this particular case, we can simplify it because we have a pure grey when the switch is off (--i: 0).

Purely grey values have equal red, green and blue values when taking into account the RGB model.

When taking into account the HSL model, the hue is irrelevant (our grey looks the same for all hues), the saturation is always 0% and only the lightness matters, determining how light or dark our grey is.

In this situation, we can always keep the hue of the non-grey value (the one we have for the “on” case, $ h1).

Since the saturation of any grey value (the one we have for the “off” case, $ s0) is always 0%, multiplying it with either 0 or 1 always gives us 0%. So, given the var(--j)*#{$ s0} term in our formula is always 0%, we can just ditch it and our saturation formula reduces to the product between the saturation of the “on” case $ s1 and the switch variable --i.

This leaves the lightness as the only component where we still need to apply the full formula.

--j: calc(1 - var(--i)); background: hsl($ h1,                  calc(var(--i)*#{$ s1}),                  calc(var(--j)*#{$ l0} + var(--i)*#{d1l}))

The above can be tested in this demo.

Similarly, let’s say we want the font-size of some text to be 2rem when our switch is off (--i: 0) and 10vw when the switch is on (--i: 1). Applying the same method, we have:

font-size: calc((1 - var(--i))*2rem + var(--i)*10vw)
Animated gif. Shows how changing the switch value from 0 to 1 changes the font-size.
Switching between two font sizes (live demo)

Alright, let’s now move on to clearing another aspect of this: what is it exactly that causes the switch to flip from on to off or the other way around?

What triggers switching

We have a few options here.

Element-based switching

This means the switch is off for certain elements and on for other elements. For example, this can be determined by parity. Let’s say we want all the even elements to be rotated and have an orange background instead of the initial grey one.

.box {   --i: 0;   --j: calc(1 - var(--i));   transform: rotate(calc(var(--i)*30deg));   background: hsl($ h1,                    calc(var(--i)*#{$ s1}),                    calc(var(--j)*#{$ l0} + var(--i)*#{$ l1}));      &:nth-child(2n) { --i: 1 } }
Screenshot. Shows a bunch of squares in a row, the even ones being rotated and having an orange background instead of the initial grey one. This is achieved by making both the transform and the background properties depend on a switch variable --i that changes with parity: it's 0 initially, but then we change it to 1 for even items.
Switching triggered by item parity (live demo, not fully functional in Edge due to calc() not working for angle values)

In the parity case, we flip the switch on for every second item (:nth-child(2n)), but we can also flip it on for every seventh item (:nth-child(7n)), for the first two items (:nth-child(-n + 2)), for all items except the first and last two (:nth-child(n + 3):nth-last-child(n + 3)). We can also flip it on just for headings or just for elements that have a certain attribute.

State-based switching

This means the switch is off when the element itself (or a parent or one of its previous siblings) is in one state and off when it’s another state. In the interactive examples from the previous section, the switch was flipped when a checkbox before our element got checked or unchecked.

We can also have something like a white link that scales up and turns orange when focused or hovered:

$ c: #f90;  $ h: round(hue($ c)/1deg); $ s: round(saturation($ c)); $ l: round(lightness($ c));  a {   --i: 0;   transform: scale(calc(1 + var(--i)*.25));   color: hsl($ h, $ s, calc(var(--i)*#{$ l} + (1 - var(--i))*100%));      &:focus, &:hover { --i: 1 } }

Since white is any hsl() value with a lightness of 100% (the hue and saturation are irrelevant), we can simplify things by always keeping the hue and saturation of the :focus/ :hover state and only changing the lightness.

Animated gif. Shows a white link that grows and turns orange when hovered or focused.
Switching triggered by state change (live demo, not fully functional in Edge due to calc() values not being supported inside scale() functions)

Media query-based switching

Another possibility is that switching is triggered by a media query, for example, when the orientation changes or when going from one viewport range to another.

Let’s say we have a white heading with a font-size of 1rem up to 320px, but then it turns orange ($ c) and the font-size becomes 5vw and starts scaling with the viewport width.

h5 {   --i: 0;   color: hsl($ h, $ s, calc(var(--i)*#{$ l} + (1 - var(--i))*100%));   font-size: calc(var(--i)*5vw + (1 - var(--i))*1rem);      @media (min-width: 320px) { --i: 1 } }
Animated gif. Shows a heading that's white and has a fixed font-size up to 320px, but as we resize the viewport above that, it becomes orange and its font-size starts scaling with the viewport width.
Switching triggered by viewport change (live demo)

Coding a more complex example from scratch

The example we dissect here is that of the expanding search shown at the beginning of this article, inspired by this Pen, which you should really check out because the code is pretty damn clever.

Animated gif. Shows a green button with a magnifier icon. Clicking this button makes it slide right and its background to turn red while a text search field slides out of it to the left and the magnifier morphs into a close (crossmark) icon.
Expanding search.

Note that from a usability point of view, having such a search box on a website may not be the best idea as one would normally expect the button following the search box to trigger the search, not close the search bar, but it’s still an interesting coding exercise, which is why I’ve chosen to dissect it here.

To begin with, my idea was to do it using only form elements. So, the HTML structure looks like this:

<input id='search-btn' type='checkbox'/> <label for='search-btn'>Show search bar</label> <input id='search-bar' type='text' placeholder='Search...'/>

What we do here is initially hide the text input and then reveal it when the checkbox before it gets checked — let’s dive into how that works!

First off, we use a basic reset and set a flex layout on the container of our input and label elements. In our case, this container is the body, but it could be another element as well. We also absolutely position the checkbox and move it out of sight (outside the viewport).

*, :before, :after {   box-sizing: border-box;   margin: 0;   padding: 0;   font: inherit }  html { overflow-x: hidden }  body {   display: flex;   align-items: center;   justify-content: center;   margin: 0 auto;   min-width: 400px;   min-height: 100vh;   background: #252525 }  [id='search-btn'] {   position: absolute;   left: -100vh }

So far, so good…

See the Pen by thebabydino (@thebabydino) on CodePen.

So what? We have to admit it’s not exciting at all, so let’s move on to the next step!

We turn the checkbox label into a big round green button and move its text content out of sight using a big negative-valued text-indent and overflow: hidden.

$ btn-d: 5em;  /* same as before */  [for='search-btn'] {   overflow: hidden;   width: $ btn-d;   height: $ btn-d;   border-radius: 50%;   box-shadow: 0 0 1.5em rgba(#000, .4);   background: #d9eb52;   text-indent: -100vw;   cursor: pointer; }

See the Pen by thebabydino (@thebabydino) on CodePen.

Next, we polish the actual search bar by:

  • giving it explicit dimensions
  • providing a background for its normal state
  • defining a different background and a glow for its focused state
  • rounding the corners on the left side using a border-radius that equals half its height
  • Cleaning up the placeholder a bit
$ btn-d: 5em; $ bar-w: 4*$ btn-d; $ bar-h: .65*$ btn-d; $ bar-r: .5*$ bar-h; $ bar-c: #ffeacc;  /* same as before */  [id='search-bar'] {   border: none;   padding: 0 1em;   width: $ bar-w;   height: $ bar-h;   border-radius: $ bar-r 0 0 $ bar-r;   background: #3f324d;   color: #fff;   font: 1em century gothic, verdana, arial, sans-serif; 	   &::placeholder {     opacity: .5;     color: inherit;     font-size: .875em;     letter-spacing: 1px;     text-shadow: 0 0 1px, 0 0 2px   } 	   &:focus {     outline: none;     box-shadow: 0 0 1.5em $ bar-c, 0 1.25em 1.5em rgba(#000, .2);     background: $ bar-c;     color: #000;   } }

See the Pen by thebabydino (@thebabydino) on CodePen.

At this point, the right edge of the search bar coincides with the left edge of the button. However, we want a bit of overlap — let’s say an overlap such that the right edge of the search bar coincides with the button’s vertical midline. Given that we have a flexbox layout with align-items: center on the container (the body in our case), the assembly made up of our two items (the bar and the button) remains middle-aligned horizontally even if we set a margin on one or on the other or on both in between those items. (On the left of the leftmost item or on the right of the rightmost item is a different story, but we won’t be getting into that now.)

Illustration showing the bar plus button assembly in the initial state (bar's right edge coinciding with the button's left edge) vs. the overlap state (the bar's right edge coincides with the button's vertical midline). In both cases, the assembly is middle aligned.
Creating overlap, keeping alignment (live demo).

That’s an overlap of .5*$ btn-d minus half a button diameter, which is equivalent to the button’s radius. We set this as a negative margin-right on the bar. We also adjust the padding on the right of the bar so that we compensate for the overlap:

$ btn-d: 5em; $ btn-r: .5*$ btn-d;  /* same as before */  [id='search-bar'] {   /* same as before */   margin-right: -$ btn-r;   padding: 0 calc(#{$ btn-r} + 1em) 0 1em; }

We now have the bar and the button in the positions for the expanded state:

See the Pen by thebabydino (@thebabydino) on CodePen.

Except the bar follows the button in DOM order, so it’s placed on top of it, when we actually want the button on top. Fortunately, this has an easy fix (at least for now — it won’t be enough later, but let’s deal with one issue at a time).

[for='search-btn'] {   /* same as before */   position: relative; }

Now that we’ve given the button a non-static position value, it’s on top of the bar:

See the Pen by thebabydino (@thebabydino) on CodePen.

In this state, the total width of the bar and button assembly is the bar width $ bar-w plus the button’s radius $ btn-r (which is half the button diameter $ btn-d) because we have an overlap for half the button. In the collapsed state, the total width of the assembly is just the button diameter $ btn-d.

Illustration showing the bar plus button assembly in the expanded state (the bar's right edge coincides with the button's vertical midline) and in the collapsed state (the bar is collapsed and the assembly is reduced to just the button). In both cases, the assembly is middle aligned.
Expanded vs. collapsed state (live).

Since we want to keep the same central axis when going from the expanded to the collapsed state, we need to shift the button to the left by half the assembly width in the expanded state (.5*($ bar-w + $ btn-r)) minus the button’s radius ($ btn-r).

We call this shift $ x and we use it with minus on the button (since we shift the button to the left and left is the negative direction of the x axis). Since we want the bar to collapse into the button, we set the same shift $ x on it, but in the positive direction (as we shift the bar to the right of the x axis).

We’re in the collapsed state when the checkbox isn’t checked and in the expanded state when it isn’t. This means our bar and button are shifted with a CSS transform when the checkbox isn’t checked and in the position we currently have them in (no transform) when the checkbox is checked.

In order to do this, we set a variable --i on the elements following our checkbox — the button (created with the label for the checkbox) and the search bar. This variable is 0 in the collapsed state (when both elements are shifted and the checkbox isn’t checked) and 1 in the expanded state (when our bar and button are in the positions they currently occupy, no shift, and the checkbox is checked).

$ x: .5*($ bar-w + $ btn-r) - $ btn-r;  [id='search-btn'] {   position: absolute;   left: -100vw; 	   ~ * {     --i: 0;     --j: calc(1 - var(--i)) /* 1 when --i is 0, 0 when --i is 1 */   } 	   &:checked ~ * { --i: 1 } }  [for='search-btn'] {   /* same as before */   /* if --i is 0, --j is 1 => our translation amount is -$ x    * if --i is 1, --j is 0 => our translation amount is 0 */   transform: translate(calc(var(--j)*#{-$ x})); }  [id='search-bar'] {   /* same as before */   /* if --i is 0, --j is 1 => our translation amount is $ x    * if --i is 1, --j is 0 => our translation amount is 0 */   transform: translate(calc(var(--j)*#{$ x})); }

And we now have something interactive! Clicking the button toggles the checkbox state (because the button has been created using the label of the checkbox).

See the Pen by thebabydino (@thebabydino) on CodePen.

Except now the button is a bit difficult to click since it’s under the text input again (because we’ve set a transform on the bar and this establishes a stacking context). The fix is pretty straightforward — we need to add a z-index to the button and this moves it above the bar.

[for='search-btn'] {   /* same as before */   z-index: 1; }

See the Pen by thebabydino (@thebabydino) on CodePen.

But we still have another bigger problem: we can see the bar coming out from under the button on the right side. In order to fix this, we set clip-path with an inset() value on the bar. This specifies a clipping rectangle with the help of the distances from the top, right, bottom and left edges of the element’s border-box. Everything outside this clipping rectangle gets cut out and only what’s inside is displayed.

Illustration showing what the four values of the inset() function represent. The first one is the offset of the top edge of the clipping rectangle with respect to the top edge of the border-box. The second one is the offset of the right edge of the clipping rectangle with respect to the right edge of the border-box. The third one is the offset of the bottom edge of the clipping rectangle with respect to the bottom edge of the border-box. The fourth one is the offset of the left edge of the clipping rectangle with respect to the left edge of the border-box. Everything outside the
How the inset() function works (live).

In the illustration above, each distance is going inward from the edges of the border-box. In this case, they’re positive. But they can also go outwards, in which case they’re negative and the corresponding edges of the clipping rectangle are outside the element’s border-box.

At first, you may think we’d have no reason to ever do that, but in our particular case, we do!

We want the distances from the top (dt), bottom (db) and left (dl) to be negative and big enough to contain the box-shadow that extends outside the element’s border-box in the :focus state as we don’t want it to get clipped out. So the solution is to create a clipping rectangle with edges outside the element’s border-box in these three directions.

The distance from the right (dr) is the full bar width $ bar-w minus a button radius $ btn-r in the collapsed case (checkbox not checked, --i: 0) and 0 in the expanded case (checkbox checked, --i: 1).

$ out-d: -3em;  [id='search-bar'] {   /* same as before */   clip-path: inset($ out-d calc(var(--j)*#{$ bar-w - $ btn-r}) $ out-d $ out-d); }

We now have a search bar and button assembly that expands and collapses on clicking the button.

See the Pen by thebabydino (@thebabydino) on CodePen.

Since we don’t want an abrupt change in between the two states, we use a transition:

[id='search-btn'] {   /* same as before */ 	   ~ * {     /* same as before */     transition: .65s;   } }

We also want our button’s background to be green in the collapsed case (checkbox not checked, --i: 0) and pink in the expanded case (checkbox checked, --i: 1). For this, we use the same technique as before:

[for='search-btn'] {   /* same as before */   $ c0: #d9eb52; // green for collapsed state   $ c1: #dd1d6a; // pink for expanded state   $ h0: round(hue($ c0)/1deg);   $ s0: round(saturation($ c0));   $ l0: round(lightness($ c0));   $ h1: round(hue($ c1)/1deg);   $ s1: round(saturation($ c1));   $ l1: round(lightness($ c1));   background: hsl(calc(var(--j)*#{$ h0} + var(--i)*#{$ h1}),                    calc(var(--j)*#{$ s0} + var(--i)*#{$ s1}),                    calc(var(--j)*#{$ l0} + var(--i)*#{$ l1})); }

Now we’re getting somewhere!

See the Pen by thebabydino (@thebabydino) on CodePen.

What we still need to do is create the icon that morphs between a magnifier in the collapsed state and an “x” in the expanded state to indicate a closing action. We do this with the :before and :after pseudo-elements. We begin by deciding on a diameter for the magnifier and how much of this diameter the width of the icon lines represent.

$ ico-d: .5*$ bar-h; $ ico-f: .125; $ ico-w: $ ico-f*$ ico-d;

We absolutely position both pseudo-elements in the middle of the button taking their dimensions into account. We then make them inherit their parent’s transition. We give the :before a background, as this will be the handle of our magnifier, make the :after round with border-radius and give it an inset box-shadow.

[for='search-btn'] {   /* same as before */ 	   &:before, &:after {     position: absolute;     top: 50%; left: 50%;     margin: -.5*$ ico-d;     width: $ ico-d;     height: $ ico-d;     transition: inherit;     content: ''   } 	   &:before {     margin-top: -.4*$ ico-w;     height: $ ico-w;     background: currentColor   }      &:after {     border-radius: 50%;     box-shadow: 0 0 0 $ ico-w currentColor   }  }

We can now see the magnifier components on the button:

See the Pen by thebabydino (@thebabydino) on CodePen.

In order to make our icon to look more like a magnifier, we translate both of its components outwards by a quarter of the magnifier’s diameter. This means translating the handle to the right, in the positive direction of the x axis by .25*$ ico-d and the main part to the left, in the negative direction of the x axis by the same .25*$ ico-d.

We also scale the handle (the :before pseudo-element) horizontally to half its width with respect to its right edge (which means a transform-origin of 100% along the x axis).

We only want this to happen in the collapsed state (checkbox not checked, --i is 0 and, consequently --j is 1), so we multiply the translation amounts by --j and also use --j to condition the scaling factor:

[for='search-btn'] {   /* same as before */ 	   &:before {     /* same as before */     height: $ ico-w;     transform:        /* collapsed: not checked, --i is 0, --j is 1        * translation amount is 1*.25*$ d = .25*$ d        * expanded: checked, --i is 1, --j is 0        * translation amount is 0*.25*$ d = 0 */       translate(calc(var(--j)*#{.25*$ ico-d}))        /* collapsed: not checked, --i is 0, --j is 1        * scaling factor is 1 - 1*.5 = 1 - .5 = .5        * expanded: checked, --i is 1, --j is 0        * scaling factor is 1 - 0*.5 = 1 - 0 = 1 */       scalex(calc(1 - var(--j)*.5))   }      &:after {     /* same as before */     transform: translate(calc(var(--j)*#{-.25*$ ico-d}))   }  }

We now have thew magnifier icon in the collapsed state:

See the Pen by thebabydino (@thebabydino) on CodePen.

Since we want both icon components to be rotated by 45deg, we add this rotation on the button itself:

[for='search-btn'] {   /* same as before */   transform: translate(calc(var(--j)*#{-$ x})) rotate(45deg); }

Now we have the look we want for the collapsed state:

See the Pen by thebabydino (@thebabydino) on CodePen.

This still leaves the expanded state, where we need to turn the round :after pseudo-element into a line. We do this by scaling it down along the x axis and bringing its border-radius from 50% to 0%. The scaling factor we use is the ratio between the width $ ico-w of the line we want to get and the diameter $ ico-d of the circle it forms in the collapsed state. We’ve called this ratio $ ico-f.

Since we only want to do this in the expanded state, when the checkbox is checked and --i is 1, we make both the scaling factor and the border-radius depend on --i and --j:

$ ico-d: .5*$ bar-h; $ ico-f: .125; $ ico-w: $ ico-f*$ ico-d;  [for='search-btn'] {   /* same as before */ 	   &:after{     /* same as before */     /* collapsed: not checked, --i is 0, --j is 1      * border-radius is 1*50% = 50%      * expanded: checked, --i is 1, --j is 0      * border-radius is 0*50% = 0 */     border-radius: calc(var(--j)*50%);     transform:        translate(calc(var(--j)*#{-.25*$ ico-d}))        /* collapsed: not checked, --i is 0, --j is 1        * scaling factor is 1 + 0*$ ico-f = 1        * expanded: checked, --i is 1, --j is 0        * scaling factor is 0 + 1*$ ico-f = $ ico-f */       scalex(calc(1 - var(--j)*.5))   } }

See the Pen by thebabydino (@thebabydino) on CodePen.

Hmm, almost, but not quite. Scaling has also shrunk our inset box-shadow along the x axis, so let’s fix that with a second inset shadow that we only get in the expanded state (when the checkbox is checked and --i is 1) and therefore, its spread and alpha depend on --i:

$ ico-d: .5*$ bar-h; $ ico-f: .125; $ ico-w: $ ico-f*$ ico-d;  [for='search-btn'] {   /* same as before */   --hsl: 0, 0%, 0%;   color: HSL(var(--hsl)); 	   &:after{     /* same as before */     box-shadow:        inset 0 0 0 $ ico-w currentcolor,        /* collapsed: not checked, --i is 0, --j is 1        * spread radius is 0*.5*$ ico-d = 0        * alpha is 0        * expanded: checked, --i is 1, --j is 0        * spread radius is 1*.5*$ ico-d = .5*$ ico-d        * alpha is 1 */       inset 0 0 0 calc(var(--i)*#{.5*$ ico-d}) HSLA(var(--hsl), var(--i))   } }

This gives us our final result!

See the Pen by thebabydino (@thebabydino) on CodePen.

A few more quick examples

The following are a few more demos that use the same technique. We won’t be building these from scratch — we’ll merely go through the basic ideas behind them.

Responsive banners

On the left, a screenshot of the wide screen scenario. In the middle, a screenshot of the normal screen scenario. On the right, a screenshot of the narrow screen scenario.
Screenshot collage (live demo, not fully functional in Edge due to using a calc() value for font-size).

In this case, our actual elements are the smaller rectangles in front, while the number squares and the bigger rectangles in the back are created with the :before and :after pseudo-elements, respectively.

The backgrounds of the number squares are individual and set using a stop list variable --slist that’s different for each item.

<p style='--slist: #51a9ad, #438c92'><!-- 1st paragraph text --></p> <p style='--slist: #ebb134, #c2912a'><!-- 2nd paragraph text --></p> <p style='--slist: #db4453, #a8343f'><!-- 3rd paragraph text --></p> <p style='--slist: #7eb138, #6d982d'><!-- 4th paragraph text --></p>

The things that influence the styles on the banners are the parity and whether we’re in the wide, normal or narrow case. These give us our switch variables:

html {   --narr: 0;   --comp: calc(1 - var(--narr));   --wide: 1; 	   @media (max-width: 36em) { --wide: 0 } 	   @media (max-width: 20em) { --narr: 1 } }  p {   --parity: 0;      &:nth-child(2n) { --parity: 1 } }

The number squares are absolutely positioned and their placement depends on parity. If the --parity switch is off (0), then they’re on the left. If it’s on (1), then they’re on the right.

A value of left: 0% aligns with the left edge of the number square along the left edge of its parent, while a value of left: 100% aligns its left edge along the parent’s right edge.

In order to have the right edge of the number square aligned with the right edge of its parent, we need to subtract its own width out of the previous 100% value. (Remember that % values in the case of offsets are relative to the parent’s dimensions.)

left: calc(var(--parity)*(100% - #{$ num-d}))

…where $ num-d is the size of the numbering square.

In the wide screen case, we also push the numbering outwards by 1em — this means subtracting 1em from the offset we have so far for odd items (having the --parity switch off) and adding 1em to the offset we have so far for even items (having the --parity switch on).

Now the question here is… how do we switch the sign? The simplest way to do it is by using the powers of -1. Sadly, we don’t have a power function (or a power operator) in CSS, even though it would be immensely useful in this case:

/*  * for --parity: 0, we have pow(-1, 0) = +1  * for --parity: 1, we have pow(-1, 1) = -1  */ pow(-1, var(--parity))

This means we have to make it work with what we do have (addition, subtraction, multiplication and division) and that leads to a weird little formula… but, hey, it works!

/*  * for --parity: 0, we have 1 - 2*0 = 1 - 0 = +1  * for --parity: 1, we have 1 - 2*1 = 1 - 2 = -1  */ --sign: calc(1 - 2*var(--parity))

This way, our final formula for the left offset, taking into account both the parity and whether we’re in the wide case (--wide: 1) or not (--wide: 0), becomes:

left: calc(var(--parity)*(100% - #{$ num-d}) - var(--wide)*var(--sign)*1em)

We also control the width of the paragraphs with these variables and max-width as we want it to have an upper limit and only fully cover its parent horizontally in the narrow case (--narr: 1):

width: calc(var(--comp)*80% + var(--narr)*100%); max-width: 35em;

The font-size also depends on whether we’re in the narrow case (--narr: 1) or not (--narr: 0):

calc(.5rem + var(--comp)*.5rem + var(--narr)*2vw)

…and so do the horizontal offsets for the :after pseudo-element (the bigger rectangle in the back) as they’re 0 in the narrow case (--narr: 1) and a non-zero offset $ off-x otherwise (--narr: 0):

right: calc(var(--comp)*#{$ off-x});  left: calc(var(--comp)*#{$ off-x});

Hover and focus effects

Animated gif. Shows red diagonal sliding bands covering the white button underneath the black text on hover/focus. On mouseout/ blur, the bands slide out the other way, not the way they entered.
Effect recording (live demo, not fully functional in Edge due to nested calc() bug).

This effect is created with a link element and its two pseudo-elements sliding diagonally on the :hover and :focus states. The link’s dimensions are fixed and so are those of its pseudo-elements, set to the diagonal of their parent $ btn-d (computed as the hypotenuse in the right triangle formed by a width and a height) horizontally and the parent’s height vertically.

The :before is positioned such that its bottom left corner coincides to that of its parent, while the :after is positioned such that its top right corner coincides with that of its parent. Since both should have the same height as their parent, the vertical placement is resolved by setting top: 0 and bottom: 0. The horizontal placement is handled in the exact same way as in the previous example, using --i as the switch variable that changes value between the two pseudo-elements and --j, its complementary (calc(1 - var(--i))):

left: calc(var(--j)*(100% - #{$ btn-d}))

We set the transform-origin of the :before to its left-bottom corner (0% 100%) and :after to its right-top corner (100% 0%), again, with the help of the switch --i and its complementary --j:

transform-origin: calc(var(--j)*100%) calc(var(--i)*100%)

We rotate both pseudo-elements to the angle between the diagonal and the horizontal $ btn-a (also computed from the triangle formed by a height and a width, as the arctangent of the ratio between the two). With this rotation, the horizontal edges meet along the diagonal.

We then shift them outwards by their own width. This means we’ll use a different sign for each of the two, again depending on the switch variable that changes value in between the :before and :after, just like in the previous example with the banners:

transform: rotate($ btn-a) translate(calc((1 - 2*var(--i))*100%))

In the :hover and :focus states, this translation needs to go back to 0. This means we multiply the amount of the translation above by the complementary --q of the switch variable --p that’s 0 in the normal state and 1 in the :hover or :focus state:

transform: rotate($ btn-a) translate(calc(var(--q)*(1 - 2*var(--i))*100%))

In order to make the pseudo-elements slide out the other way (not back the way they came in) on mouse-out or being out of focus, we set the switch variable --i to the value of --p for :before and to the value of --q for :after, reverse the sign of the translation, and make sure we only transition the transform property.

Responsive infographic

On the left, a screenshot of the wide screen scenario. We have a three row, two column grid with the third row collapsed (height zero). The first level heading occupies either the column on the right (for odd items) or the one on the left (for even items). The second level heading is on the other column and on the first row, while the paragraph text is below the second level heading on the second row. On the right, a screenshot of the narrower scenario. In this case, the third row has a height enough to fit the paragraph text, but the second column is collapsed. The first and second level heading occupy the first and second row respectively.
Screenshot collage with the grid lines and gaps highlighted (live demo, no Edge support due to CSS variable and calc() bugs).

In this case, we have a three-row, two-column grid for each item (article element), with the third row collapsed in the wide screen scenario and the second column collapsed in the narrow screen scenario. In the wide screen scenario, the widths of the columns depend on the parity. In the narrow screen scenario, the first column spans the entire content-box of the element and the second one has width 0. We also have a gap in between the columns, but only in the wide screen scenario.

// formulas for the columns in the wide screen case, where // $ col-a-wide is for second level heading + paragraph // $ col-b-wide is for the first level heading $ col-1-wide: calc(var(--q)*#{$ col-a-wide} + var(--p)*#{$ col-b-wide}); $ col-2-wide: calc(var(--q)*#{$ col-b-wide} + var(--p)*#{$ col-a-wide});  // formulas for the general case, combining the wide and normal scenarios $ row-1: calc(var(--i)*#{$ row-1-wide} + var(--j)*#{$ row-1-norm}); $ row-2: calc(var(--i)*#{$ row-2-wide} + var(--j)*#{$ row-2-norm}); $ row-3: minmax(0, auto); $ col-1: calc(var(--i)*#{$ col-1-wide} + var(--j)*#{$ col-1-norm}); $ col-2: calc(var(--i)*#{$ col-2-wide});  $ art-g: calc(var(--i)*#{$ art-g-wide});  html {   --i: var(--wide, 1); // 1 in the wide screen case   --j: calc(1 - var(--i));    @media (max-width: $ art-w-wide + 2rem) { --wide: 0 } }  article {   --p: var(--parity, 0);   --q: calc(1 - var(--p));   --s: calc(1 - 2*var(--p));   display: grid;   grid-template: #{$ row-1} #{$ row-2} #{$ row-3}/ #{$ col-1} #{$ col-2};   grid-gap: 0 $ art-g;   grid-auto-flow: column dense;    &:nth-child(2n) { --parity: 1 } }

Since we’ve set grid-auto-flow: column dense, we can get away with only setting the first level heading to cover an entire column (second one for odd items and first one for even items) in the wide screen case and let the second level heading and the paragraph text fill the first free available cells.

// wide case, odd items: --i is 1, --p is 0, --q is 1 // we're on column 1 + 1*1 = 2 // wide case, even items: --i is 1, --p is 1, --q is 0 // we're on column 1 + 1*0 = 1 // narrow case: --i is 0, so var(--i)*var(--q) is 0 and we're on column 1 + 0 = 1 grid-column: calc(1 + var(--i)*var(--q));  // always start from the first row // span 1 + 2*1 = 3 rows in the wide screen case (--i: 1) // span 1 + 2*0 = 1 row otherwise (--i: 0) grid-row: 1/ span calc(1 + 2*var(--i));

For each item, a few other properties depend on whether we’re in the wide screen scenario or not.

The vertical margin, vertical and horizontal padding values, box-shadow offsets and blur are all bigger in the wide screen case:

$ art-mv: calc(var(--i)*#{$ art-mv-wide} + var(--j)*#{$ art-mv-norm}); $ art-pv: calc(var(--i)*#{$ art-pv-wide} + var(--j)*#{$ art-p-norm}); $ art-ph: calc(var(--i)*#{$ art-ph-wide} + var(--j)*#{$ art-p-norm}); $ art-sh: calc(var(--i)*#{$ art-sh-wide} + var(--j)*#{$ art-sh-norm});  article {   /* other styles */   margin: $ art-mv auto;   padding: $ art-pv $ art-ph;   box-shadow: $ art-sh $ art-sh calc(3*#{$ art-sh}) rgba(#000, .5); }

We have a non-zero border-width and border-radius in the wide screen case:

$ art-b: calc(var(--i)*#{$ art-b-wide}); $ art-r: calc(var(--i)*#{$ art-r-wide});  article {   /* other styles */   border: solid $ art-b transparent;   border-radius: $ art-r; }

In the wide screen scenario, we limit the items’ width, but let it be 100% otherwise.

$ art-w: calc(var(--i)*#{$ art-w-wide} + var(--j)*#{$ art-w-norm});  article {   /* other styles */   width: $ art-w; }

The direction of the padding-box gradient also changes with the parity:

background:    linear-gradient(calc(var(--s)*90deg), #e6e6e6, #ececec) padding-box,    linear-gradient(to right bottom, #fff, #c8c8c8) border-box;

In a similar manner, margin, border-width, padding, width, border-radius, background gradient direction, font-size or line-height for the headings and the paragraph text also depend on whether we’re in the wide screen scenario or not (and, in the case of the first level heading’s border-radius or background gradient direction, also on the parity).

The post DRY Switching with CSS Variables: The Difference of One Declaration appeared first on CSS-Tricks.

CSS-Tricks

, , ,
[Top]