DEV Community

Cover image for Simple Expandable Card
Amir
Amir

Posted on

Simple Expandable Card

Expandable cards on the Web?

You have most likely used the Apple app store, and been fascinated by the smooth card transitions to a full-screen page. It’s a common transition on native mobile apps, but it’s quite rare on the web. But fear not, we are going to create a bootleg version of it on the web, and for the cherry on top, we won’t use any additional library.

Just a couple of things before we begun

As you most likely will find out, there is much room for improvement. For example you might want to wrap the expanded card inside a dialog element to trap the focus and help with the accessibility or do a shallow path change on expand and shrink. In case you need a production-ready method for animating between two states you can use libraries such as framer-motion1, or wait for the View Transitions API2 to become available on Safari3.

How to do it?

It's quite simple, there are 4 main steps in the process of expanding a card.

Create your 2 cards

Step 0 - Create your 2 cards

The two cards should have the same portion for the transition between them, you can see an example of it in image 0/4.

Scaling down the expanded card to the same width

Step 1 - Scaling down the expanded card to the same width

In this step we need to scale down the extended card to the same width as the original card, we can use the transform: scale(x); for this action.

Masking to hide the extended portion

Step 2 - Masking to hide the extended portion

We have to use a clip-path to hide the extended portion of the expanded card. We can use the clip-mask: inset(0 0 x% 0); to mask the expanded card from the bottom up.

Positioning the cards

Step 3 - Positioning the cards

Since we want to expand the card from the original card, we have to position it at the same point. We can get the position of the original card with el.getBoundingClientRect() function, then use the transform: translate(left, top); with the addition of position: fixed; to place the expanded card at the same position of the original card.

Animating

Step 4 - Animating

Now the only thing between us and the buttery smooth animation is the addition of transition property in the CSS.

Let's code it

Starting point

Since in this guide, we are trying to focus on the animation mechanism, here are the base HTML and css files.

index.html

<!DOCTYPE html>
<html dir="ltr" lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link href="style.css" rel="stylesheet" />
    <title>Expandable Card</title>
  </head>
  <body>
    <button data-state="visible" class="card" id="card">
      <img src="./banner.jpg" loading="lazy" class="img" />
      <div class="header">
        <h3>Subtitle</h3>
        <h2>Title</h2>
      </div>
    </button>
    <div data-state="hidden" class="card" id="expandableCard">
      <img src="./banner.jpg" loading="lazy" class="img" />
      <div class="header">
        <h3>Subtitle</h3>
        <h2>Title</h2>
      </div>
      <button id="close">Close</button>
      <div class="content">
        Lorem ipsum, dolor sit amet consectetur adipisicing elit. Repellendus,
        dolor! Error nostrum quos illum aperiam! Mollitia praesentium excepturi
        dolorum, odio perferendis consectetur earum possimus quis in eum ab
        consequuntur explicabo.
      </div>
    </div>
    <script type="module" src="/main.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

style.css

 {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
    Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
  background: #111;
  color: #eee;
  display: flex;
  justify-content: center;
  align-content: center;
  align-items: center;
  flex-direction: column;
  height: 100vh;
}

.card .header h2 {
  font-size: 2rem;
}

.card .header h3 {
  font-size: 1rem;
  font-weight: 300;
  opacity: 0.75;
}

button {
  display: flex;
  border: none;
  background: transparent;
  color: inherit;
  font-size: inherit;
  text-align: inherit;
}

button:hover {
  cursor: pointer;
}

.card {
  --padding-amount: 1.4rem;
  --border-radius: 1.5rem;
  width: min(400px, 80vw);
  position: relative;
  clip-path: inset(0 0 0 0 round var(--border-radius));
  background: #333;
  transform-origin: top left;
  overflow: auto;
}

.card .img {
  width: 100%;
  aspect-ratio: 16 / 9;
}

.card .header {
  position: absolute;
  top: 0;
  left: 0;
  transform-origin: top left;
  transform: translate(var(--padding-amount), var(--padding-amount));
}

.card .content {
  padding: 2rem;
}

.card #close {
  position: absolute;
  top: 0;
  right: 0;
  background: #111;
  border-radius: var(--border-radius);
  padding: 0.4rem 1.6rem;
  z-index: 10;
  opacity: 0;
  transition: opacity 0.15s ease-in-out;
  transform: translate(calc(var(--padding-amount) * -1), var(--padding-amount));
}

.card#expandableCard {
  width: 100%;
  height: 100vh;
}
Enter fullscreen mode Exit fullscreen mode

Some explanations

There are a couple of shortcomings and limitations to my method. We need to use the round value inside the clip-path to add the border radius. Due to the clip-path we can’t add box-shadow.

Sending data to css

Since we need to know the width, height, and positioning of the main card, we need to pass the data to css. We can achieve it by using css variables. Additionally, we have to pass down the current state of the cards. For this purpose the HTML attribute tags are wonderful.

Getting the elements

// main.js
const ogCard = document.getElementById('card');
const exCard = document.getElementById('expandableCard');
const close = document.getElementById('close');
Enter fullscreen mode Exit fullscreen mode

UpdatePos function

// main.js
/**
 * Generates required data for animation
 */
const updatePos = () => {
  const { top, left, width, height } = ogCard.getBoundingClientRect();

  const screenWidth = window.innerWidth;
  const screenHeight = window.innerHeight;

  // Data required to position the element
  exCard.style.setProperty('--top', `${top}px`);
  exCard.style.setProperty('--left', `${left}px`);

  // Data required to scale the element
  exCard.style.setProperty('--screen-width', `${screenWidth}`);
  exCard.style.setProperty('--screen-height', `${screenHeight}`);
  exCard.style.setProperty('--width', `${width}`);
  exCard.style.setProperty('--height', `${height}`);
};
Enter fullscreen mode Exit fullscreen mode

Adding event listeners and toggling states

// main.js
/**
 * onExpand adds the required data to extendableCard.
 */
const onExpand = () => {
  updatePos();

  // Since we have to wait for the variables to be applied,
  // the states have to be updated on a next page render.
  // For this purpose  we add a timeout with 1ms timeout time.
  setTimeout(() => {
    // Setting data attributes on card elements
    exCard.setAttribute('data-state', 'expanded');
    ogCard.setAttribute('data-state', 'hidden');
  }, 1);
};

ogCard.addEventListener('click', onExpand);

/**
 * onShrinks shrinks down the card and hide it.
 */
const onShrink = () => {
  updatePos();

  exCard.setAttribute('data-state', 'shrink');

  setTimeout(() => {
    // Setting data attributes on card elements
    exCard.setAttribute('data-state', 'hidden');
    ogCard.setAttribute('data-state', 'visible');
  }, 300 + 1);
};

close.addEventListener('click', onShrink);
Enter fullscreen mode Exit fullscreen mode

Setting Up the Animation

Scaling down the expanded card to the same width

/* index.css */
.card#expandableCard {
  --width-scale: calc(var(--screen-width) / var(--width));
  transform: scale(calc(1 / var(--width-scale)));
  clip-path: inset(
    0 0 0 0 round calc(var(--border-radius) * var(--width-scale))
  );
}

.card#expandableCard .header {
  transform: translate(
      calc(var(--padding-amount) * var(--width-scale)),
      calc(var(--padding-amount) * var(--width-scale))
    ) scale(var(--width-scale));
}
Enter fullscreen mode Exit fullscreen mode

Here are some additional explanations, since we are scaling everything down, the border radiuses and the inside elements will also get scaled-down, therefore we need to add some changes to make them look the same as the main card. Firstly we have to multiply the clip-path border radius by the scaling factor, additionally, we need to multiply the distance and increase the scale of the header group by the same scaling value.

Positioning the expanded card

/* style.css */
.card#expandableCard {
  --width-scale: calc(var(--screen-width) / var(--width));
  top: 0;
  top: 0;
  position: fixed;
  opacity: 0.5;
  transform: translate(var(--left), var(--top)) scale(
      calc(1 / var(--width-scale))
    );
}
Enter fullscreen mode Exit fullscreen mode

We have to position the expanded card in the same place as the original card, which is quite easy to do. Keep in mind the reason for re-writing the scale(calc( 1 - var(--width-scale) ) portion is due to the fact we are replacing the previous transform.

Masking the extended portion

/* style.css */
.card#expandableCard {
  --bottom-clip: calc(
    calc(var(--screen-height) - var(--height) * var(--width-scale)) / var(
        --screen-height
      ) * 100%
  );
  clip-path: inset(
    0 0 var(--bottom-clip) 0 round calc(
        var(--border-radius) * var(--width-scale)
      )
  );
}
Enter fullscreen mode Exit fullscreen mode

Since the expanded card is taller than the original card, we have to mask some parts of it. For this, we have to use the following form.

$(expandedHeight - ( originalHeight * scale ))(\frac{100}{ expandedHeight})$

The result will be the amount that we need to mask in percentage.

Setting up the transitions

Hiding the expandable card
/* style.css */
.card#expandableCard {
  opacity: 0;
}
.card#expandableCard[data-state='hidden'] {
  pointer-events: none;
}
Enter fullscreen mode Exit fullscreen mode
Hiding the original card while the expanded card is displayed
/* style.css */
.card[data-state='hidden'] {
  opacity: 0;
}
Enter fullscreen mode Exit fullscreen mode
Adding transition to the expandable card while it isn’t hidden
/* style.css */
.card#expandableCard:not([data-state='hidden']) {
  transition: transform 0.3s ease-in-out, clip-path 0.3s ease-in-out;
}
.card#expandableCard:not([data-state='hidden']) .header {
  transition: transform 0.3s ease-in-out;
}
Enter fullscreen mode Exit fullscreen mode
Resetting the shrunk values to default
/* style.css */
.card#expandableCard[data-state='expanded'] {
  opacity: 1;
  clip-path: inset(0 0 0 0 round 0);
  transform: translate(0, 0);
}
.card#expandableCard[data-state='expanded'] .header {
  transform: translate(var(--padding-amount), var(--padding-amount)) scale(1);
}
Enter fullscreen mode Exit fullscreen mode
Displaying the close button while the card is fully expanded
/* style.css */
.card#expandableCard[data-state='expanded'] #close {
  opacity: 1;
}
Enter fullscreen mode Exit fullscreen mode

The complete example

You can find the complete example on the following Github repository4 or the following CodeSandbox embed.


  1. https://www.framer.com/motion/ 

  2. https://developer.chrome.com/docs/web-platform/view-transitions/ 

  3. https://caniuse.com/mdn-api_viewtransition 

  4. https://github.com/amir4rab/expandable-card 

Top comments (0)

The discussion has been locked. New comments can't be added.