No Time Dad

A blog about web development written by a busy dad

Fancy Stat Card Example

Intro

I thought it would be fun to build on the simple stat cards I did in a previous post. The simple cards are great because they’re easy to implement, but they are lacking flair. It would be nice if the cards stood out more.

What I’d like to do is build a new version of the stat card that has more color, hover effects, and transition effects. My goal is to still keep the card simple while adding some subtle style. Below is a demo of what the final product will look like.

I am going to use React components instead of plain html for this example. There are a couple reason for this:

  1. I think breaking down elements into smaller components makes it easier to understand.
  2. It is easier for me. Since this is a Gatsby blog, I am already creating the demos as React components and this saves me from having to translate the component back into html for the code preview.

This example does not have any state or use any event handlers. You can translate this example into any framework or plain html. With that being said, I don’t intend for this to be a React tutorial either. Some basic React knowledge, specifically about props, would be helpful but not required. Focus on the html and css.

Structuring the card

Our card will consist of a Card wrapper div and two child sibling divs. One child div for the card header and one for the card content. The Card selector itself will define width and min-width properties to ensure that the cards are always the same size.

import React from 'react';
import * as styles from './fancy-stat-cards.module.css';


const Card = () => (
  <div className={styles.Card}>
    ...
  </div>
);
.Card {
  width: 200px;
  min-width: 200px;
}
Card header

The card header will contain an svg icon from heroicons and some text that describes the data being displayed. We’ll use flexbox to align the content and add a :hover pseudo-action to the element that changes the opacity slightly.

It is important that the header has clickable links that go to the same place as the button. Otherwise we have an accessibility issue because the button is not always visible and clickable.

Our new CardHeader component with it’s corresponding css is shown below. The updated Card component is also shown.

const Card = ({ headerIcon, headerText }) => (
  <div className={styles.Card}>
    <CardHeader headerIcon={headerIcon} headerText={headerText} />
    ...
  </div>
);

const CardHeader = ({ headerIcon, headerText }) => (
  <div className={styles.Card__Header}>
    <a href="#somewhere" className={styles.Header__Icon_Container}>{headerIcon}</a>
    <a href="#somewhere" className={styles.Header__Text}>{headerText}</a>
  </div>
);
...
.Card__Header {
  /* Initialize the flexbox container */
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  padding: 1rem;
  color: white;
  font-size: 18px;
  background-color: cornflowerblue;
  /* We only want to round the top corners */
  border-top-left-radius: 0.5rem;
  border-top-right-radius: 0.5rem;
}

.Card__Header:hover {
  /* Show the user that they're hovering */
  opacity: 0.9;
}

.Header__Icon_Container {
  /* The icon is a link, so normalize the css */
  text-decoration: none;
  color: white;
}

.Header__Icon {
  width: 3.5rem;
  height: 3.5rem;
}

.Header__Text {
  /* The text is also a link, so normalize the css */
  text-decoration: none;
  color: white;
}
...
...

Quick side note: I like to create separate components for my svg icons. Below is the component for the svg icon shown above.

const ClockSvg = () => (
  <svg className={styles.Header__Icon} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
  </svg>
);
.Header__Icon {
  width: 3.5rem;
  height: 3.5rem;
}
Card content

The content section of the card is where some css magic happens. The idea is that when the user hovers on a card, a button should appear from the bottom and replace the number. To make this happen I am going to use the transform property and adjust the translateY value in a transition. I’ll also set the opacity of the button to zero when the page first loads, but then change it to one when the user hovers. This is a hack to show and hide the element without toggling the display property. The reason for this is you can’t transition on the display property. Instead we’ll make it appear hidden by making it invisible with opacity: 0;. An important thing to know about making elements invisible via opacity is that they’re still going to take up space on the page. Setting display: none; means they aren’t visible and do not take space on the page.

The structure of the content section is a container div with two child divs. The first child div displays the the metrics, and the second child div displays the button.

const CardContent = ({ contentNumber }) => (
  <div className={styles.Card__Content}>
    <div className={styles.Content__Text}>{contentNumber}</div>
    <ViewButton />
  </div>
);
.Card__Content {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
  border-bottom-left-radius: 0.5rem;
  border-bottom-right-radius: 0.5rem;
}

.Content__Text {
  opacity: 0.8;
  font-size: 28px;
  font-weight: 600;
  /* A hack to remove space from the invisible button */
  margin-bottom: -40px;
}

A couple things happen when the user hovers over the card. The box-shadow increases slightly, and the button comes up from the bottom to replace the text. Most of the css applied to the button is cosmetic and gives the button its shape. Spoiler: it is not actually a button element. It is worth pointing out the transform: translateY(15px); property moves the button under the text, and the transition: transform .2s ease-in-out; property makes the button appear from the bottom. Below is our html and css for the button.

const ViewButton = () => (
  <a href="#somewhere" className={styles.Button}>
    <ExploreSvg />
    View
  </a>
);

const ExploreSvg = () => (
  <svg className={styles.Button__Icon} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  </svg>
);
.Button {
  display: flex;
  align-items: center;
  /* The button is invisible on page load */
  opacity: 0;
  padding: 0.5rem 1.5rem;
  text-decoration: none;
  color: rgba(0, 0, 0, 0.9);
  border: 2px solid rgba(0, 0, 0, 0.5);
  border-radius: 0.75rem;
  /* Initially, position the button under the text */
  transform: translateY(15px);
  /* Later, move the button over the text */
  transition: transform .2s ease-in-out;
}

.Button:hover {
  background-color: rgb(236, 233, 233);
}

.Button__Icon {
  width: 1.5rem;
  height: 1.5rem;
  padding-right: 0.25rem;
}
Hovering

Hovering over any part of the card should cause the box-shadow to increase and the button to replace the text. What that means is we need to add :hover pseudo-action to the parent div that modifies the css in a child div. You can do this by adding defining the hover on the parent and adding the child selector directly after.

.Parent:hover .Child {
  /* Properties to apply to the child */
  ...
}

The content text will be hidden via opacity: 0; while the button will be shown via opacity: 1;. Additionally, we’ll move the button up through the transform property.

.Card:hover .Card__Content {
  /* Increase the shadow slightly on hover */
  box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.15);
}

.Card:hover .Content__Text {
  /* Hide the text */
  opacity: 0;
}

.Card:hover .Button {
  /* Show the button and move it up from the bottom */
  opacity: 1;
  transform: translateY(0px);
}

Conclusion

I had fun with this project. Parts of it were a little tedious, like getting the spacing right for the button and text elements, but not terrible overall. Would I use these cards? I probably would, but I am not convinced showing and hiding elements is a great idea. I think there are still accessibility concerns with doing that. Always think about accessibility. Most people don’t and I wish that would change.

Final code
import React from 'react';
import * as styles from './fancy-stat-cards.module.css';

export const CardsDemo = () => (
  <div className={styles.Container}>
    <Card
      headerIcon={<ClockSvg />}
      headerText="Sessions"
      contentNumber="1,245">
    </Card>
    <Card
      headerIcon={<UsersSvg />}
      headerText="Views"
      contentNumber="5,662">
    </Card>
    <Card
      headerIcon={<DownloadSvg />}
      headerText="Clicks"
      contentNumber="498">
    </Card>
  </div>
);

const Card = ({ headerIcon, headerText, contentNumber }) => (
  <div className={styles.Card}>
    <CardHeader headerIcon={headerIcon} headerText={headerText} />
    <CardContent contentNumber={contentNumber} />
  </div>
);

const CardHeader = ({ headerIcon, headerText }) => (
  <div className={styles.Card__Header}>
    <a href="#somewhere" className={styles.Header__Icon_Container}>{headerIcon}</a>
    <a href="#somewhere" className={styles.Header__Text}>{headerText}</a>
  </div>
);


const CardContent = ({ contentNumber }) => (
  <div className={styles.Card__Content}>
    <div className={styles.Content__Text}>{contentNumber}</div>
    <ViewButton />
  </div>
);

const ViewButton = () => (
  <a href="#somewhere" className={styles.Button}>
    <ExploreSvg />
    View
  </a>
);

/* SVG ICON COMPONENTS **/ 
const UsersSvg = () => (
  <svg className={styles.Header__Icon} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
  </svg>
);

const ClockSvg = () => (
  <svg className={styles.Header__Icon} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
  </svg>
);

const DownloadSvg = () => (
  <svg className={styles.Header__Icon} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
  </svg>
);

const ExploreSvg = () => (
  <svg className={styles.Button__Icon} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  </svg>
);
.Container {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: center;
  padding-bottom: 2rem;
}

.Card {
  width: 200px;
  min-width: 200px;
}

.Card:hover .Card__Content {
  box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.15);
}

.Card:hover .Content__Text {
  opacity: 0;
}

.Card:hover .Button {
  opacity: 1;
  transform: translateY(0px);
}

.Card__Header {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  padding: 1rem;
  color: white;
  font-size: 18px;
  background-color: cornflowerblue;
  border-top-left-radius: 0.5rem;
  border-top-right-radius: 0.5rem;
}

.Card__Header:hover {
  opacity: 0.9;
}

.Header__Icon_Container {
  text-decoration: none;
  color: white;
}

.Header__Icon {
  width: 3.5rem;
  height: 3.5rem;
}

.Header__Text {
  text-decoration: none;
  color: white;
}

.Card__Content {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
  border-bottom-left-radius: 0.5rem;
  border-bottom-right-radius: 0.5rem;
}

.Content__Text {
  opacity: 0.8;
  font-size: 28px;
  font-weight: 600;
  margin-bottom: -40px;
}

.Button {
  display: flex;
  align-items: center;
  opacity: 0;
  padding: 0.5rem 1.5rem;
  text-decoration: none;
  color: rgba(0, 0, 0, 0.9);
  border: 2px solid rgba(0, 0, 0, 0.5);
  border-radius: 0.75rem;
  transform: translateY(15px);
  transition: transform .2s ease-in-out;
}

.Button:hover {
  background-color: rgb(236, 233, 233);
}

.Button__Icon {
  width: 1.5rem;
  height: 1.5rem;
  padding-right: 0.25rem;
}

@media (min-width: 768px) {
  .Container {
    flex-direction: row;
  }
}