A Fancy Hover Effect For Your Avatar

Do you know that kind of effect where someone’s head is poking through a circle or hole? The famous Porky Pig animation where he waves goodbye while popping out of a series of red rings is the perfect example, and Kilian Valkhof actually re-created that here on CSS-Tricks a while back.

I have a similar idea but tackled a different way and with a sprinkle of animation. I think it’s pretty practical and makes for a neat hover effect you can use on something like your own avatar.

See that? We’re going to make a scaling animation where the avatar seems to pop right out of the circle it’s in. Cool, right? Don’t look at the code and let’s build this animation together step-by-step.

The HTML: Just one element

If you haven’t checked the code of the demo and you are wondering how many divs this’ll take, then stop right there, because our markup is nothing but a single image element:

<img src="" alt="">

Yes, a single element! The challenging part of this exercise is using the smallest amount of code possible. If you have been following me for a while, you should be used to this. I try hard to find CSS solutions that can be achieved with the smallest, most maintainable code possible.

I wrote a series of articles here on CSS-Tricks where I explore different hover effects using the same HTML markup containing a single element. I go into detail on gradients, masking, clipping, outlines, and even layout techniques. I highly recommend checking those out because I will re-use many of the tricks in this post.

An image file that’s square with a transparent background will work best for what we’re doing. Here’s the one I’m using if you want start with that.

Designed by Cang

I’m hoping to see lots of examples of this as possible using real images — so please share your final result in the comments when you’re done so we can build a collection!

Before jumping into CSS, let’s first dissect the effect. The image gets bigger on hover, so we’ll for sure use transform: scale() in there. There’s a circle behind the avatar, and a radial gradient should do the trick. Finally, we need a way to create a border at the bottom of the circle that creates the appearance of the avatar behind the circle.

Let’s get to work!

The scale effect

Let’s start by adding the transform:

img {
  width: 280px;
  aspect-ratio: 1;
  cursor: pointer;
  transition: .5s;
}
img:hover {
  transform: scale(1.35);
}

Nothing complicated yet, right? Let’s move on.

The circle

We said that the background would be a radial gradient. That’s perfect because we can create hard stops between the colors of a radial gradient, which make it look like we’re drawing a circle with solid lines.

img {
  --b: 5px; /* border width */

  width: 280px;
  aspect-ratio: 1;
  background:
    radial-gradient(
      circle closest-side,
      #ECD078 calc(99% - var(--b)),
      #C02942 calc(100% - var(--b)) 99%,
      #0000
    );
  cursor: pointer;
  transition: .5s;
}
img:hover {
  transform: scale(1.35);
}

Note the CSS variable, --b, I’m using there. It represents the thickness of the “border” which is really just being used to define the hard color stops for the red part of the radial gradient.

The next step is to play with the gradient size on hover. The circle needs to keep its size as the image grows. Since we are applying a scale() transformation, we actually need to decrease the size of the circle because it otherwise scales up with the avatar. So, while the image scales up, we need the gradient to scale down.

Let’s start by defining a CSS variable, --f, that defines the “scale factor”, and use it to set the size of the circle. I’m using 1 as the default value, as in that’s the initial scale for the image and the circle that we transform from.

Here is a demo to illustrate the trick. Hover to see what is happening behind the scenes:

I added a third color to the radial-gradient to better identify the area of the gradient on hover:

radial-gradient(
  circle closest-side,
  #ECD078 calc(99% - var(--b)),
  #C02942 calc(100% - var(--b)) 99%,
  lightblue
);

Now we have to position our background at the center of the circle and make sure it takes up the full height. I like to declare everything directly on the background shorthand property, so we can add our background positioning and make sure it doesn’t repeat by tacking on those values right after the radial-gradient():

background: radial-gradient() 50% / calc(100% / var(--f)) 100% no-repeat;

The background is placed at the center (50%), has a width equal to calc(100%/var(--f)), and has a height equal to 100%.

Nothing scales when --f is equal to 1 — again, our initial scale. Meanwhile, the gradient takes up the full width of the container. When we increase --f, the element’s size grows — thanks to the scale() transform — and the gradient’s size decreases.

Here’s what we get when we apply all of this to our demo:

We’re getting closer! We have the overflow effect at the top, but we still need to hide the bottom part of the image, so it looks like it is popping out of the circle rather than sitting in front of it. That’s the tricky part of this whole thing and is what we’re going to do next.

The bottom border

I first tried tackling this with the border-bottom property, but I was unable to find a way to match the size of the border to the size to the circle. Here’s the best I could get and you can immediately see it’s wrong:

The actual solution is to use the outline property. Yes, outline, not border. In a previous article, I show how outline is powerful and allows us to create cool hover effects. Combined with outline-offset, we have exactly what we need for our effect.

The idea is to set an outline on the image and adjust its offset to create the bottom border. The offset will depend on the scaling factor the same way the gradient size did.

Now we have our bottom “border” (actually an outline) combined with the “border” created by the gradient to create a full circle. We still need to hide portions of the outline (from the top and the sides), which we’ll get to in a moment.

Here’s our code so far, including a couple more CSS variables you can use to configure the image size (--s) and the “border” color (--c):

img {
  --s: 280px; /* image size */
  --b: 5px; /* border thickness */
  --c: #C02942; /* border color */
  --f: 1; /* initial scale */

  width: var(--s);
  aspect-ratio: 1;
  cursor: pointer;
  border-radius: 0 0 999px 999px;
  outline: var(--b) solid var(--c);
  outline-offset: calc((1 / var(--f) - 1) * var(--s) / 2 - var(--b));
  background: 
    radial-gradient(
      circle closest-side,
      #ECD078 calc(99% - var(--b)),
      var(--c) calc(100% - var(--b)) 99%,
      #0000
    ) 50% / calc(100% / var(--f)) 100% no-repeat;
  transform: scale(var(--f));
  transition: .5s;
}
img:hover {
  --f: 1.35; /* hover scale */
}

Since we need a circular bottom border, we added a border-radius on the bottom side, allowing the outline to match the curvature of the gradient.

The calculation used on outline-offset is a lot more straightforward than it looks. By default, outline is drawn outside of the element’s box. And in our case, we need it to overlap the element. More precisely, we need it to follow the circle created by the gradient.

Diagram of the background transition.

When we scale the element, we see the space between the circle and the edge. Let’s not forget that the idea is to keep the circle at the same size after the scale transformation runs, which leaves us with the space we will use to define the outline’s offset as illustrated in the above figure.

Let’s not forget that the second element is scaled, so our result is also scaled… which means we need to divide the result by f to get the real offset value:

Offset = ((f - 1) * S/2) / f = (1 - 1/f) * S/2

We add a negative sign since we need the outline to go from the outside to the inside:

Offset = (1/f - 1) * S/2

Here’s a quick demo that shows how the outline follows the gradient:

You may already see it, but we still need the bottom outline to overlap the circle rather than letting it bleed through it. We can do that by removing the border’s size from the offset:

outline-offset: calc((1 / var(--f) - 1) * var(--s) / 2) - var(--b));

Now we need to find how to remove the top part from the outline. In other words, we only want the bottom part of the image’s outline.

First, let’s add space at the top with padding to help avoid the overlap at the top:

img {
  --s: 280px; /* image size */
  --b: 5px;   /* border thickness */
  --c: #C02942; /* border color */
  --f: 1; /* initial scale */

  width: var(--s);
  aspect-ratio: 1;
  padding-block-start: calc(var(--s)/5);
  /* etc. */
}
img:hover {
  --f: 1.35; /* hover scale */
}

There is no particular logic to that top padding. The idea is to ensure the outline doesn’t touch the avatar’s head. I used the element’s size to define that space to always have the same proportion.

Note that I have added the content-box value to the background:

background:
  radial-gradient(
    circle closest-side,
    #ECD078 calc(99% - var(--b)),
    var(--c) calc(100% - var(--b)) 99%,
    #0000
  ) 50%/calc(100%/var(--f)) 100% no-repeat content-box;

We need this because we added padding and we only want the background set to the content box, so we must explicitly tell the background to stop there.

Adding CSS mask to the mix

We reached the last part! All we need to do is to hide some pieces, and we are done. For this, we will rely on the mask property and, of course, gradients.

Here is a figure to illustrate what we need to hide or what we need to show to be more accurate

Showing how the mask applies to the bottom portion of the circle.

The left image is what we currently have, and the right is what we want. The green part illustrates the mask we must apply to the original image to get the final result.

We can identify two parts of our mask:

  • A circular part at the bottom that has the same dimension and curvature as the radial gradient we used to create the circle behind the avatar
  • A rectangle at the top that covers the area inside the outline. Notice how the outline is outside the green area at the top — that’s the most important part, as it allows the outline to be cut so that only the bottom part is visible.

Here’s our final CSS:

img {
  --s: 280px; /* image size */
  --b: 5px; /* border thickness */
  --c: #C02942; /* border color */
  --f: 1; /* initial scale */

  --_g: 50% / calc(100% / var(--f)) 100% no-repeat content-box;
  --_o: calc((1 / var(--f) - 1) * var(--s) / 2 - var(--b));

  width: var(--s);
  aspect-ratio: 1;
  padding-top: calc(var(--s)/5);
  cursor: pointer;
  border-radius: 0 0 999px 999px;
  outline: var(--b) solid var(--c);
  outline-offset: var(--_o);
  background: 
    radial-gradient(
      circle closest-side,
      #ECD078 calc(99% - var(--b)),
      var(--c) calc(100% - var(--b)) 99%,
      #0000) var(--_g);
  mask:
    linear-gradient(#000 0 0) no-repeat
    50% calc(-1 * var(--_o)) / calc(100% / var(--f) - 2 * var(--b)) 50%,
    radial-gradient(
      circle closest-side,
      #000 99%,
      #0000) var(--_g);
  transform: scale(var(--f));
  transition: .5s;
}
img:hover {
  --f: 1.35; /* hover scale */
}

Let’s break down that mask property. For starters, notice that a similar radial-gradient() from the background property is in there. I created a new variable, --_g, for the common parts to make things less cluttered.

--_g: 50% / calc(100% / var(--f)) 100% no-repeat content-box;

mask:
  radial-gradient(
    circle closest-side,
    #000 99%,
    #0000) var(--_g);

Next, there’s a linear-gradient() in there as well:

--_g: 50% / calc(100% / var(--f)) 100% no-repeat content-box;

mask:
  linear-gradient(#000 0 0) no-repeat
    50% calc(-1 * var(--_o)) / calc(100% / var(--f) - 2 * var(--b)) 50%,
  radial-gradient(
    circle closest-side,
    #000 99%,
    #0000) var(--_g);

This creates the rectangle part of the mask. Its width is equal to the radial gradient’s width minus twice the border thickness:

calc(100% / var(--f) - 2 * var(--b))

The rectangle’s height is equal to half, 50%, of the element’s size.

We also need the linear gradient placed at the horizontal center (50%) and offset from the top by the same value as the outline’s offset. I created another CSS variable, --_o, for the offset we previously defined:

--_o: calc((1 / var(--f) - 1) * var(--s) / 2 - var(--b));

One of the confusing things here is that we need a negative offset for the outline (to move it from outside to inside) but a positive offset for the gradient (to move from top to bottom). So, if you’re wondering why we multiply the offset, --_o, by -1, well, now you know!

Here is a demo to illustrate the mask’s gradient configuration:

Hover the above and see how everything move together. The middle box illustrates the mask layer composed of two gradients. Imagine it as the visible part of the left image, and you get the final result on the right!

Wrapping up

Oof, we’re done! And not only did we wind up with a slick hover animation, but we did it all with a single HTML <img> element. Just that and less than 20 lines of CSS trickery!

Sure, we relied on some little tricks and math formulas to reach such a complex effect. But we knew exactly what to do since we identified the pieces we needed up-front.

Could we have simplified the CSS if we allowed ourselves more HTML? Absolutely. But we’re here to learn new CSS tricks! This was a good exercise to explore CSS gradients, masking, the outline property’s behavior, transformations, and a whole bunch more. If you felt lost at any point, then definitely check out my series that uses the same general concepts. It sometimes helps to see more examples and use cases to drive a point home.

I will leave you with one last demo that uses photos of popular CSS developers. Don’t forget to show me a demo with your own image so I can add it to the collection!


A Fancy Hover Effect For Your Avatar originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

CSS Grid and Custom Shapes, Part 3

After Part 1 and Part 2, I am back with a third article to explore more fancy shapes. Like the previous articles, we are going to combine CSS Grid with clipping and masking to create fancy layouts for image galleries.

CSS Grid and Custom Shapes series

Should I read the previous articles before?

It’s not mandatory but highly recommended to cover as many tricks as possible. You can also read them in any order, but following along in chronological is a good idea to see how we arrived here.

Enough talking, let’s jump straight to our first example.

Before digging into the CSS, let’s check the markup:

<div class="gallery">
  <img src="..." alt="...">
  <img src="..." alt="...">
  <img src="..." alt="...">
  <img src="..." alt="...">
</div>

Nothing but a few <img> tags in a div wrapper, right? Remember, the main challenge for this series is to work with the smallest amount of HTML possible. All the examples we’ve seen throughout this series use the exact same HTML markup. No extra divs, wrappers, and whatnot. All that we need are images contained in a wrapper element.

Let’s check the CSS now:

.gallery {
  --g: 6px; /* the gap */

  display: grid;
  width: 450px; /* the size */
  aspect-ratio: 1; /* equal height */
  grid: auto-flow 1fr / repeat(3, 1fr);
  gap: var(--g);
}
.gallery img:nth-child(2) {
  grid-area: 1 / 2 / span 2 / span 2;
}
.gallery img:nth-child(3) {
  grid-area: 2 / 1 / span 2 / span 2;
}

Basically, this is a square grid with three equal columns. From there, all that’s happening is the second and third images are explicitly placed on the grid, allowing the first and last images to lay out automatically around them.

This automatic behavior is a powerful feature of CSS Grid called “auto-placement”. Same thing with the number of rows — none of them are explicitly defined. The browser “implicitly” creates them based on the placement of the items. I have a very detailed article that explores both concepts.

You might be wondering what’s going on with those grid and grid-area property values. They look strange and are tough to grok! That’s because I chose the CSS grid shorthand property, which is super useful but accepts an unseemly number of values from its constituent properties. You can see all of them in the Almanac.

But what you really need to know is this:

grid: auto-flow 1fr / repeat(3, 1fr);

…is equivalent to this:

grid-template-columns: repeat(3, 1fr);
grid-auto-rows: 1fr;
DevTools style rules for the grid property.
You can also use your favorite DevTools for further proof.

Same for the grid-area property. If we open DevTools and inspect our declaration: grid-area: 1/2/span 2/span 2; you will get the following:

grid-area: 1 / 2 / span 2 / span 2;

…that is the same as writing all this out:

grid-row-start: 1; /* 1st row */
grid-column-start: 2; /* 2nd column */
grid-row-end: span 2; /* take 2 rows */
grid-column-end: span 2; /* take 2 columns */

Same deal for the other grid-area declaration. When we put it all together, here’s what we get:

The different images labeled by number on the grid.

Yes, the second and third images are overlapped in the middle. That’s no mistake! I purposely spanned them on top of one another so that I can apply a clip-path to cut a portion from each one and get the final result:

Showing the effect with and without clip-path.

How do we do that? We can cut the bottom-left corner of the second image (img:nth-child(2)) with the CSS clip-path property:

clip-path: polygon(0 0, 100% 0, 100% 100%, calc(50% + var(--g) / 4) 100%, 0 calc(50% - var(--g) / 4))

And the top-right corner of the third one:

clip-path: polygon(0 0, calc(50% - var(--g) / 4) 0, 100% calc(50% + var(--g) / 4), 100% 100%, 0 100%);

I know, I know. That’s a lot of numbers and whatnot. I do have an article that details the technique.

That’s it, we have our first grid of images! I added a grayscale filter on the <img> selector to get that neat little hover effect.

The Split Image Reveal

Let’s try something different. We can take what we learned about clipping the corner of an image and combine it with a nice effect to reveal the full image on hover.

The grid configuration for this one is less intense than the last one, as all we need are two overlapping images:

.gallery {
  display: grid;
}
.gallery > img {
  grid-area: 1 / 1;
  width: 350px; /* the size */
  aspect-ratio: 1; /* equal height */
}

Two images that are the same size are stacked on top of each other (thanks to grid-area: 1 / 1).

The hover effect relies on animating clip-path. We will dissect the code of the first image to see how it works, then plug the same thing into the second image with updated values. Notice, though that we have three different states:

  1. When no images are hovered, half of each image is revealed.
  2. When we hover over the first image, it is more fully revealed but retains a small corner clip.
  3. When we hover over the second image, the first one has only a small triangle visible.
Showing the three clipping states of the hover effect.

In each case, we have a triangular shape. That means we need a three-point polygon for the clip-path value.

What? The second state isn’t a triangle, but more of a square with a cut corner.

You are right, but if we look closely we can see a “hidden” triangle. Let’s add a box-shadow to the images.

A ha! Did you notice it?

Showing the transition between states with the overflow shape revealed to explain how it works.

What sort of magic is this? It’s a little known fact that clip-path accepts values outside the 0%-100% range, which allows us to create “overflowing” shapes. (Yes, I just coined this. You’re welcome.) This way, we only have to work with three points instead of the five it would take to make the same shape from the visible parts. Optimized CSS for the win!

This is the code after we plug in the polygon values into the clip-path property:

.gallery > img:first-child {
  clip-path: polygon(0 0, calc(100% + var(--_p)) 0 , 0 calc(100% + var(--_p)))
}
.gallery > img:last-child {
  clip-path: polygon(100% 100%, 100% calc(0% - var(--_p)), calc(0% - var(--_p)) 100%)
}

Notice the --_p variable. I’m using that to optimize the code a bit as we add the hover transition. Instead of updating the whole clip-path we only update this variable to get the movement. Here is a video to see how the points should move between each state:

We can take slap a transition on the <img> selector, then update the --_p variable on the states to get the final effect:

.gallery {
  --g: 8px; /* the gap */
}
.gallery > img {
  /* etc. */
  --_p: calc(-1 * var(--g));
  transition: .4s .1s;
}
.gallery:hover > img:last-child,
.gallery:hover > img:first-child:hover{
  --_p: calc(50% - var(--g));
}
.gallery:hover > img:first-child,
.gallery:hover > img:first-child:hover + img {
  --_p: calc(-50% - var(--g));
}

If we don’t consider the gap (defined as --g in the code) between the images, then the three values of --_p are 0%, 50%, and -50%. Each one defines one of the states we explained previously.

The Pie Image Reveal

Let’s increase the difficulty level from that last one and try to do the same trick but with four images instead of two.

Cool, right? Each image is a quarter of a circle and, on hover, we have an animation that transforms an image into a full circle that covers the remaining images. The effect may look impossible because there is no way to rotate points and transform them to fill the circle. In reality, though, we are not rotating any points at all. It’s an illusion!

For this example, I will only focus on the clip-path animation since the configuration of the grid is the same as the previous example: four equally-sized images stacked on top of each other.

And a video worth a boring and long explanation:

The clip-path is formed by seven points, where three of them are in a fixed position and the others move as shown in the video. The effect looks less cool when it’s running slowly but we can see how the clip-path morphs between shapes.

The effect is a little better if we add border-radius and we make it faster:

And by making it even faster like in the original example, we get the perfect illusion of one quarter of a circle morphing into a full circle. Here’s the polygon value for our clip-path on the first image in the sequence:

.gallery > img:nth-child(1) {
  clip-path: polygon(50% 50%, calc(50% * var(--_i, 0)) calc(120% * var(--_i, 0)), 0 calc(100% * var(--_i, 0)),0 0, 100% 0, 100% calc(100% * var(--_i, 0)), calc(100% - 50% * var(--_i, 0)) calc(120% * var(--_i, 0)));
}
.gallery > img:hover {
 --_i: 1;
}

As usual, I am using a variable to optimize the code. The variable will switch between 0 and 1 to update the polygon.

The same goes for the others image but with a different clip-path configuration. I know that the values may look hard to decipher but you can always use online tools like Clippy to visualize the values.

The Mosaic of Images

You know mosaics, right? It’s an art style that creates decorative designs out of smaller individual pieces, like colored stones. But it can also be a composite image made up of other smaller images.

And, you guessed it: we can totally do that sort of thing in CSS!

First, let’s imagine what things are like if clip-path were taken out of the mix and all we had were five overlapping images:

I am cheating a little in this video because I am inspecting the code to identify the area of each image, but this is what you need to do in your head. For each image, try to complete the missing part to see the full rectangle and, with this, we can identify the position and size of each one.

We need to find how many columns and rows we need for the grid:

  1. We have two big images placed next to each other that each fill half the grid width and the full grid height. That means will probably need two columns (one for both images) and one row (for the full height of the grid).
  2. We have the image in the middle that overlaps the two other images. That means we actually need four columns instead of two, though we still only need the one row.
  3. The last two images each fill half the grid, just like the first two images. But they’re only half the height of the grid. We can use the existing columns we already have, but we’re going to need two rows instead of one to account for these images being half the grid height.
That leaves us with a tidy 4×2 grid.

I don’t want you to think that the way I sliced this up is the only way to do it. This is merely how I’ve made sense of it. I am sure there are other configurations possible to get the same layout!

Let’s take that information and define our grid, then place the images on it:

.gallery {
  display: grid;
  grid: repeat(2, 1fr) / repeat(4, 1fr); 
  aspect-ratio: 2;
}
.gallery img:nth-child(1) {
  grid-area: 1 / 1 / span 2 / span 2;
}
.gallery img:nth-child(2) {
  grid-area: 1 / 2 / span 2 / span 2;
}
.gallery img:nth-child(3) {
  grid-area: span 2 / span 2 / -1 / -1;
}
.gallery img:nth-child(4) {
  grid-area: 2 / 1 / span 1 / span 2;
}
.gallery img:nth-child(5) {
  grid-area: span 1 / span 2 / -1 / -1;
}

I think you get the idea of what’s happening here now that we’ve seen a few examples using the same approach. We define a grid and place images on it explicitly, using grid-area so the images overlap.

OK, but the aspect-ratio is different this time.

It is! If you get back to the reasoning we made, we have the first two images that are square next to each other having the same size. This means that the width of the grid needs to be equal to twice its height. Hence, aspect-ratio: 2.

Now it’s time for the clip-path values. We have four triangles and a rhombus.

Showing the three unique shapes and the clip-path values that create them.
We’re only showing the three unique shapes we’re making instead of the five total shapes.

Again, I’m using Clippy for all this math-y stuff. But, honestly, I can write many simple shapes by hand, having spent several years working closely with clip-path, and I know you can too with practice!

The Complex Mosaic of Images

Let’s increase the difficulty and try another mosaic, this time with less symmetry and more complex shapes.

Don’t worry, you will see that it’s the same concept as the one we just made! Again, let’s imagine each image is a rectangle, then go about defining the grid based on what we see.

We’ll start with two images:

They are both squares. The first image is equal to half the size of the second image. The first image takes up less than one half of the grid width, while the second image takes up more than half giving us a total of two columns with a different size (the first one is equal to half the second one). The first image is half the height, so let’s automatically assume we need two rows as well.

Let’s add another image to the layout

This one makes things a bit more complex! We need to draw some lines to identify how to update the grid configuration.

We will move from a 2×2 grid to four columns and three rows. Pretty asymmetric, right? Before we try to figure out that complete sizing, let’s see if the same layout holds up when we add the other images.

Looks like we still need more rows and columns for everything to fall into place. Based on the lines in that image, we’re going to have a total of five columns and four rows.

The logic is simple even though the layout is complex, right? We add the images one by one to find the right configuration that fits everything. Now we need to identify the size of each column and row.

If we say the smallest row/column is equal to one fraction of the grid (1fr) we will get:

grid-template-columns: 1fr 1fr 2fr 3fr 5fr;

…for the columns, and:

grid-template-rows: 3fr 1fr 2fr 2fr;

…for the rows. We can consolidate this using the grid shorthand property again:

grid: 3fr 1fr 2fr 2fr / 1fr 1fr 2fr 3fr 5fr;

You know the drill! Place the images on the grid and apply a clip-path on them:

.gallery img:nth-child(1) {
  grid-area: 1 / 1 /span 2 / span 3;
  clip-path: polygon(0 0, 100% 0, 0 100%);
}
.gallery img:nth-child(2) {
  grid-area: 1/2/span 3/span 3;
  clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
}
.gallery img:nth-child(3) {
  grid-area: 1 / span 2 / -1 / -1;
  clip-path: polygon(0 0, 100% 0, 100% 100%);
}
.gallery img:nth-child(4) {
  grid-area: span 3 / 1 / -1 / span 3;
  clip-path: polygon(25% 0, 100% 60%, 50% 100%, 0 100%, 0 20%);
}
.gallery img:nth-child(5) {
  grid-area: span 3/span 3/-1/-1;
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
}

We can stop here and our code is fine, but we will do a little more to optimize the clip-path values. Since we don’t have any gaps between our images, we can use the fact that our images overlap to slim things down. Here is a video to illustrate the idea:

As you can see, the image in the middle (the one with the camera) doesn’t need a clip-path. because the other images overlap it, giving us the shape without any additional work! And notice that we can use the same overflowing three-point clip-path concept we used earlier on the image in the bottom-left to keep the code smaller there as well.

In the end, we have a complex-looking grid of images with only four clip-path declarations — all of them are three-point polygons!

Wrapping up

Wow, right? I don’t know about you, but I never get bored of seeing what CSS can do these days. It wasn’t long ago that all of this would have taken verbose hackery and definitely some JavaScript.

Throughout this series, we explored many, many different types of image grids, from the basic stuff to the complex mosaics we made today. And we got a lot of hands-on experience working with CSS clipping — something that you will definitely be able to use on other projects!

But before we end this, I have some homework for you…

Here are two mosaics that I want you to make using what we covered here. One is on the “easier” side, and the other is a bit tricky. It would be really awesome to see your work in the comments, so link them up! I’m curious to see if your approach is different from how I’d go about it!


CSS Grid and Custom Shapes, Part 3 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Fancy Image Decorations: Outlines and Complex Animations

We’ve spent the last two articles in this three-part series playing with gradients to make really neat image decorations using nothing but the <img> element. In this third and final piece, we are going to explore more techniques using the CSS outline property. That might sound odd because we generally use outline to draw a simple line around an element — sorta like border but it can only draw all four sides at once and is not part of the Box Model.

We can do more with it, though, and that’s what I want to experiment with in this article.

Fancy Image Decorations series

Let’s start with our first example — an overlay that disappears on hover with a cool animation:

We could accomplish this by adding an extra element over the image, but that’s what we’re challenging ourselves not to do in this series. Instead, we can reach for the CSS outline property and leverage that it can have a negative offset and is able to overlap its element.

img {
  --s: 250px; /* the size of the image */
  --b: 8px;   /* the border thickness*/
  --g: 14px;  /* the gap */
  --c: #4ECDC4;

  width: var(--s);
  aspect-ratio: 1;
  outline: calc(var(--s) / 2) solid #0009;
  outline-offset: calc(var(--s) / -2);
  cursor: pointer;
  transition: 0.3s;
}
img:hover {
  outline: var(--b) solid var(--c);
  outline-offset: var(--g);
}

The trick is to create an outline that’s as thick as half the image size, then offset it by half the image size with a negative value. Add in some semi-transparency with the color and we have our overlay!

Diagram showing the size of the outline sround the image and how it covers the image on hover.

The rest is what happens on :hover. We update the outline and the transition between both outlines creates the cool hover effect. The same technique can also be used to create a fading effect where we don’t move the outline but make it transparent.

Instead of using half the image size in this one, I am using a very big outline thickness value (100vmax) while applying a CSS mask. With this, there’s no longer a need to know the image size — it trick works at all sizes!

Diagram showing how adding a mask clips the extra outline around the image.

You may face issues using 100vmax as a big value in Safari. If it’s the case, consider the previous trick where you replace the 100vmax with half the image size.

We can take things even further! For example, instead of simply clipping the extra outline, we can create shapes and apply a fancy reveal animation.

Cool right? The outline is what creates the yellow overlay. The clip-path clips the extra outline to get the star shape. Then, on hover, we make the color transparent.

Oh, you want hearts instead? We can certainly do that!

Imagine all the possible combinations we can create. All we have to do is to draw a shape with a CSS mask and/or clip-path and combine it with the outline trick. One solution, infinite possibilities!

And, yes, we can definitely animate this as well. Let’s not forget that clip-path is animatable and mask relies on gradients — something we covered in super great detail in the first two articles of this series.

I know, the animation is a bit glitchy. This is more of a demo to illustrate the idea rather than the “final product” to be used in a production site. We’d wanna optimize things for a more natural transition.

Here is a demo that uses mask instead. It’s the one I teased you with at the end of the last article:

Did you know that the outline property was capable of so much awesomeness? Add it to your toolbox for fancy image decorations!

Combine all the things!

Now that we have learned many tricks using gradients, masks, clipping, and outline, it’s time for the grand finale. Let’s cap off this series by combine all that we have learned the past few weeks to showcase not only the techniques, but demonstrate just how flexible and modular these approaches are.

If you were seeing these demos for the first time, you might assume that there’s a bunch of extra divs wrappers and pseudo-elements being used to pull them off. But everything is happening directly on the <img> element. It’s the only selector we need to get these advanced shapes and effects!

Wrapping up

Well, geez, thanks for hanging out with me in this three-part series the past few weeks. We explored a slew of different techniques that turn simple images into something eye-catching and interactive. Will you use everything we covered? Certainly not! But my hope is that this has been a good exercise for you to dig into advanced uses of CSS features, like gradients, mask, clip-path, and outline.

And we did everything with just one <img> element! No extra div wrappers and pseudo-elements. Sure, it’s a constraint we put on ourselves, but it also pushed us to explore CSS and try to find innovative solutions to common use cases. So, before pumping extra markup into your HTML, think about whether CSS is already capable of handling the task.

Fancy Image Decorations series


Fancy Image Decorations: Outlines and Complex Animations originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Fancy Image Decorations: Masks and Advanced Hover Effects

Welcome to Part 2 of this three-part series! We are still decorating images without any extra elements and pseudo-elements. I hope you already took the time to digest Part 1 because we will continue working with a lot of gradients to create awesome visual effects. We are also going to introduce the CSS mask property for more complex decorations and hover effects.

Fancy Image Decorations series

  • Single Element Magic
  • Masks and Advanced Hover Effects (you are here!)
  • Outlines and Complex Animations (coming October 28 )

Let’s turn to the first example we’re working on together…

The Postage Stamp

Believe or not, all it takes to make postage stamp CSS effect is two gradients and a filter:

img {
  --r: 10px; /* control the radius of the circles */
  padding: calc(2 * var(--r));
  filter: grayscale(.4);
  background: 
    radial-gradient(var(--r),#0000 98%,#fff) round
      calc(-1.5 * var(--r)) calc(-1.5 * var(--r)) / calc(3 * var(--r)) calc(3 * var(--r)),
    linear-gradient(#fff 0 0) no-repeat
      50% / calc(100% - 3 * var(--r)) calc(100% - 3 * var(--r));
}

As we saw in the previous article, the first step is to make space around the image with padding so we can draw a background gradient and see it there. Then we use a combination of radial-gradient() and linear-gradient() to cut those circles around the image.

Here is a step-by-step illustration that shows how the gradients are configured:

Note the use of the round value in the second step. It’s very important for the trick as it ensures the size of the gradient is adjusted to be perfectly aligned on all the sides, no matter what the image width or height is.

From the specification: The image is repeated as often as will fit within the background positioning area. If it doesn’t fit a whole number of times, it is rescaled so that it does.

The Rounded Frame

Let’s look at another image decoration that uses circles…

This example also uses a radial-gradient(), but this time I have created circles around the image instead of the cut-out effect. Notice that I am also using the round value again. The trickiest part here is the transparent gap between the frame and the image, which is where I reach for the CSS mask property:

img {
  --s: 20px; /* size of the frame */
  --g: 10px; /* the gap */
  --c: #FA6900; 

  padding: calc(var(--g) + var(--s));
  background: 
    radial-gradient(farthest-side, var(--c) 97%, #0000) 
      0 0 / calc(2 * var(--s)) calc(2 * var(--s)) round;
  mask:
    conic-gradient(from 90deg at calc(2 * var(--s)) calc(2 * var(--s)), #0000 25%, #000 0)
      calc(-1 * var(--s)) calc(-1 * var(--s)),
    linear-gradient(#000 0 0) content-box;
}

Masking allows us to show the area of the image — thanks to the linear-gradient() in there — as well as 20px around each side of it — thanks to the conic-gradient(). The 20px is nothing but the variable --s that defines the size of the frame. In other words, we need to hide the gap.

Here’s what I mean:

The linear gradient is the blue part of the background while the conic gradient is the red part of the background. That transparent part between both gradients is what we cut from our element to create the illusion of an inner transparent border.

The Inner Transparent Border

For this one, we are not going to create a frame but rather try something different. We are going to create a transparent inner border inside our image. Probably not that useful in a real-world scenario, but it’s good practice with CSS masks.

Similar to the previous example, we are going to rely on two gradients: a linear-gradient() for the inner part, and a conic-gradient() for the outer part. We’ll leave a space between them to create the transparent border effect.

img {
  --b: 5px;  /* the border thickness */
  --d: 20px; /* the distance from the edge */

  --_g: calc(100% - 2 * (var(--d) + var(--b)));
  mask:
    conic-gradient(from 90deg at var(--d) var(--d), #0000 25%, #000 0)
      0 0 / calc(100% - var(--d)) calc(100% - var(--d)),
    linear-gradient(#000 0 0) 50% / var(--_g) var(--_g) no-repeat;
}
Detailing the parts of the image that correspond to CSS variables.

You may have noticed that the conic gradient of this example has a different syntax from the previous example. Both are supposed to create the same shape, so why are they different? It’s because we can reach the same result using different syntaxes. This may look confusing at first, but it’s a good feature. You are not obliged to find the solution to achieve a particular shape. You only need to find one solution that works for you out of the many possibilities out there.

Here are four ways to create the outer square using gradients:

There are even more ways to pull this off, but you get the point.

There is no Best™ approach. Personally, I try to find the one with the smallest and most optimized code. For me, any solution that requires fewer gradients, fewer calculations, and fewer repeated values is the most suitable. Sometimes I choose a more verbose syntax because it gives me more flexibility to change variables and modify things. It comes with experience and practice. The more you play with gradients, the more you know what syntax to use and when.

Let’s get back to our inner transparent border and dig into the hover effect. In case you didn’t notice, there is a cool hover effect that moves that transparent border using a font-size trick. The idea is to define the --d variable with a value of 1em. This variables controls the distance of the border from the edge. We can transform like this:

--_d: calc(var(--d) + var(--s) * 1em)

…giving us the following updated CSS:

img {
  --b: 5px;  /* the border thickness */
  --d: 20px; /* the distance from the edge */
  --o: 15px; /* the offset on hover */
  --s: 1;    /* the direction of the hover effect (+1 or -1)*/

  --_d: calc(var(--d) + var(--s) * 1em);
  --_g: calc(100% - 2 * (var(--_d) + var(--b)));
  mask:
    conic-gradient(from 90deg at var(--_d) var(--_d), #0000 25%, #000 0)
     0 0 / calc(100% - var(--_d)) calc(100% - var(--_d)),
    linear-gradient(#000 0 0) 50% / var(--_g) var(--_g) no-repeat;
  font-size: 0;
  transition: .35s;
}
img:hover {
  font-size: var(--o);
}

The font-size is initially equal to 0 ,so 1em is also equal to 0 and --_d is be equal to --d. On hover, though, the font-size is equal to a value defined by an --o variable that sets the border’s offset. This, in turn, updates the --_d variable, moving the border by the offset. Then I add another variable, --s, to control the sign that decides whether the border moves to the inside or the outside.

The font-size trick is really useful if we want to animate properties that are otherwise unanimatable. Custom properties defined with @property can solve this but support for it is still lacking at the time I’m writing this.

The Frame Reveal

We made the following reveal animation in the first part of this series:

We can take the same idea, but instead of a border with a solid color we will use a gradient like this:

If you compare both codes you will notice the following changes:

  1. I used the same gradient configuration from the first example inside the mask property. I simply moved the gradients from the background property to the mask property.
  2. I added a repeating-linear-gradient() to create the gradient border.

That’s it! I re-used most of the same code we already saw — with super small tweaks — and got another cool image decoration with a hover effect.

/* Solid color border */

img {
  --c: #8A9B0F; /* the border color */
  --b: 10px;   /* the border thickness*/
  --g: 5px;  /* the gap on hover */

  padding: calc(var(--g) + var(--b));
  --_g: #0000 25%, var(--c) 0;
  background: 
    conic-gradient(from 180deg at top var(--b) right var(--b), var(--_g))
     var(--_i, 200%) 0 / 200% var(--_i, var(--b)) no-repeat,
    conic-gradient(at bottom var(--b) left  var(--b), var(--_g))
     0 var(--_i, 200%) / var(--_i, var(--b)) 200% no-repeat;
  transition: .3s, background-position .3s .3s;
  cursor: pointer;
}
img:hover {
  --_i: 100%;
  transition: .3s, background-size .3s .3s;
}
/* Gradient color border */

img {
  --b: 10px; /* the border thickness*/
  --g: 5px;  /* the gap on hover */
  background: repeating-linear-gradient(135deg, #F8CA00 0 10px, #E97F02 0 20px, #BD1550 0 30px);

  padding: calc(var(--g) + var(--b));
  --_g: #0000 25%, #000 0;
  mask: 
    conic-gradient(from 180deg at top var(--b) right var(--b), var(--_g))
     var(--_i, 200%) 0 / 200% var(--_i, var(--b)) no-repeat,
    conic-gradient(at bottom var(--b) left  var(--b), var(--_g))
     0 var(--_i, 200%) / var(--_i, var(--b)) 200% no-repeat,
    linear-gradient(#000 0 0) content-box;
  transition: .3s, mask-position .3s .3s;
  cursor: pointer;
}
img:hover {
  --_i: 100%;
  transition: .3s, mask-size .3s .3s;
}

Let’s try another frame animation. This one is a bit tricky as it has a three-step animation:

The first step of the animation is to make the bottom edge bigger. For this, we adjust the background-size of a linear-gradient():

You are probably wondering why I am also adding the top edge. We need it for the third step. I always try to optimize the code I write, so I am using one gradient to cover both the top and bottom sides, but the top one is hidden and revealed later with a mask.

For the second step, we add a second gradient to show the left and right edges. But this time, we do it using background-position:

We can stop here as we already have a nice effect with two gradients but we are here to push the limits so let’s add a touch of mask to achieve the third step.

The trick is to make the top edge hidden until we show the bottom and the sides and then we update the mask-size (or mask-position) to show the top part. As I said previously, we can find a lot of gradient configurations to achieve the same effect.

Here is an illustration of the gradients I will be using:

I am using two conic gradients having a width equal to 200%. Both gradients cover the area leaving only the top part uncovered (that part will be invisible later). On hover, I slide both gradients to cover that part.

Here is a better illustration of one of the gradients to give you a better idea of what’s happening:

Now we put this inside the mask property and we are done! Here is the full code:

img {
  --b: 6px;  /* the border thickness*/
  --g: 10px; /* the gap */
  --c: #0E8D94;

  padding: calc(var(--b) + var(--g));
  --_l: var(--c) var(--b), #0000 0 calc(100% - var(--b)), var(--c) 0;
  background:
    linear-gradient(var(--_l)) 50%/calc(100% - var(--_i,80%)) 100% no-repeat,
    linear-gradient(90deg, var(--_l)) 50% var(--_i,-100%)/100% 200% no-repeat;  
  mask:
    conic-gradient(at 50% var(--b),#0000 25%, #000 0) calc(50% + var(--_i, 50%)) / 200%,
    conic-gradient(at 50% var(--b),#000 75%, #0000 0) calc(50% - var(--_i, 50%)) / 200%;
  transition: 
    .3s calc(.6s - var(--_t,.6s)) mask-position, 
    .3s .3s background-position,
    .3s var(--_t,.6s) background-size,
    .4s transform;
  cursor: pointer;
}
img:hover {
  --_i: 0%;
  --_t: 0s;
  transform: scale(1.2);
}

I have also introduced some variables to optimize the code, but you should be used to this right now.

What about a four-step animation? Yes, it’s possible!

No explanation for this because it’s your homework! Take all that you have learned in this article to dissect the code and try to articulate what it’s doing. The logic is similar to all the previous examples. The key is to isolate each gradient to understand each step of the animation. I kept the code un-optimized to make things a little easier to read. I do have an optimized version if you are interested, but you can also try to optimize the code yourself and compare it with my version for additional practice.

Wrapping up

That’s it for Part 2 of this three-part series on creative image decorations using only the <img> element. We now have a good handle on how gradients and masks can be combined to create awesome visual effects, and even animations — without reaching for extra elements or pseudo-elements. Yes, a single <img> tag is enough!

We have one more article in this series to go. Until then, here is a bonus demo with a cool hover effect where I use mask to assemble a broken image.

Fancy Image Decorations series

  • Single Element Magic
  • Masks and Advanced Hover Effects (you are here!)
  • Outlines and Complex Animations (coming October 28 )

Fancy Image Decorations: Masks and Advanced Hover Effects originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Fancy Image Decorations: Single Element Magic

As the title says, we are going to decorate images! There’s a bunch of other articles out there that talk about this, but what we’re covering here is quite a bit different because it’s more of a challenge. The challenge? Decorate an image using only the <img> tag and nothing more.

That right, no extra markup, no divs, and no pseudo-elements. Just the one tag.

Sounds difficult, right? But by the end of this article — and the others that make up this little series — I’ll prove that CSS is powerful enough to give us great and stunning results despite the limitation of working with a single element.

Fancy Image Decorations series

  • Single Element Magic — you are here
  • Masks and Advanced Hover Effects (coming October 21 )
  • Outlines and Complex Animations (coming October 28 )

Let’s start with our first example

Before digging into the code let’s enumerate the possibilities for styling an <img> without any extra elements or pseudo-elements. We can use border, box-shadow, outline, and, of course, background. It may look strange to add a background to an image because we cannot see it as it will be behind the image — but the trick is to create space around the image using padding and/or border and then draw our background inside that space.

I think you know what comes next since I talked about background, right? Yes, gradients! All the decorations we are going to make rely on a lot of gradients. If you’ve followed me for a while, I think this probably comes as no surprise to you at all. 😁

Let’s get back to our first example:

img {
  --s: 10px; /* control the size */
  padding: var(--s);
  border: calc(2 * var(--s)) solid #0000;
  outline: 1px solid #000;
  outline-offset: calc(-1 * var(--s));
  background: conic-gradient(from 90deg at 1px 1px, #0000 25%, #000 0);
}

We are defining padding and a transparent border using the variable --s to create a space around our image equal to three times that variable.

Why are we using both padding and border instead of one or the other? We can get by using only one of them but I need this combination for my gradient because, by default, the initial value of background-clip is border-box and background-origin is equal to padding-box.

Here is a step-by-step illustration to understand the logic:

Initially, we don’t have any borders on the image, so our gradient will create two segments with 1px of thickness. (I am using 3px in this specific demo so it’s easier to see.) We add a colored border and the gradient still gives us the same result inside the padding area (due to background-origin) but it repeats behind the border. If we make the color of the border transparent, we can use the repetition and we get the frame we want.

The outline in the demo has a negative offset. That creates a square shape at the top of the gradient. That’s it! We added a nice decoration to our image using one gradient and an outline. We could have used more gradients! But I always try to keep my code as simple as possible and I found that adding an outline is better that way.

Here is a gradient-only solution where I am using only padding to define the space. Still the same result but with a more complex syntax.

Let’s try another idea:

For this one, I took the previous example removed the outline, and applied a clip-path to cut the gradient on each side. The clip-path value is a bit verbose and confusing but here is an illustration to better see its points:

Side-by-side comparison of the image with and without using clip-path.

I think you get the main idea. We are going to combine backgrounds, outlines, clipping, and some masking to achieve different kinds of decorations. We are also going to consider some cool hover animations as an added bonus! What we’ve looked at so far is merely a small overview of what’s coming!

The Corner-Only Frame

This one takes four gradients. Each gradient covers one corner and, on hover, we expand them to create a full frame around the image. Let’s dissect the code for one of the gradients:

--b: 5px; /* border thickness */
background: conic-gradient(from 90deg at top var(--b) left var(--b), #0000 90deg, darkblue 0) 0 0;
background-size: 50px 50px; 
background-repeat: no-repeat;

We are going to draw a gradient with a size equal to 50px 50px and place it at the top-left corner (0 0). For the gradient’s configuration, here’s a step-by-step illustration showing how I reached that result.

We tend to think that gradients are only good for transitioning between two colors. But in reality, we can do so much more with them! They are especially useful when it comes to creating different shapes. The trick is to make sure we have hard stops between colors — like in the example above — rather than smooth transitions:

#0000 25%, darkblue 0

This is basically saying: “fill the gradient with a transparent color until 25% of the area, then fill the remaining area with darkblue.

You might be scratching your head over the 0 value. It’s a little hack to simplify the syntax. In reality, we should use this to make a hard stop between colors:

#0000 25%, darkblue 25%

That is more logical! The transparent color ends at 25% and darkblue starts exactly where the transparency ends, making a hard stop. If we replace the second one with 0, the browser will do the job for us, so it is a slightly more efficient way to go about it.

Somewhere in the specification, it says:

if a color stop or transition hint has a position that is less than the specified position of any color stop or transition hint before it in the list, set its position to be equal to the largest specified position of any color stop or transition hint before it.

0 is always smaller than any other value, so the browser will always convert it to the largest value that comes before it in the declaration. In our case, that number is 25%.

Now, we apply the same logic to all the corners and we end with the following code:

img {
  --b: 5px; /* border thickness */
  --c: #0000 90deg, darkblue 0; /* define the color here */
  padding: 10px;
  background:
    conic-gradient(from 90deg  at top    var(--b) left  var(--b), var(--c)) 0 0,
    conic-gradient(from 180deg at top    var(--b) right var(--b), var(--c)) 100% 0,
    conic-gradient(from 0deg   at bottom var(--b) left  var(--b), var(--c)) 0 100%,
    conic-gradient(from -90deg at bottom var(--b) right var(--b), var(--c)) 100% 100%;
  background-size: 50px 50px; /* adjust border length here */
  background-repeat: no-repeat;
}

I have introduced CSS variables to avoid some redundancy as all the gradients use the same color configuration.

For the hover effect, all I’m doing is increasing the size of the gradients to create the full frame:

img:hover {
  background-size: 51% 51%;
}

Yes, it’s 51% instead of 50% — that creates a small overlap and avoids possible gaps.

Let’s try another idea using the same technique:

This time we are using only two gradients, but with a more complex animation. First, we update the position of each gradient, then increase their sizes to create the full frame. I also introduced more variables for better control over the color, size, thickness, and even the gap between the image and the frame.

img {
  --b: 8px;  /* border thickness*/
  --s: 60px; /* size of the corner*/
  --g: 14px; /* the gap*/
  --c: #EDC951; 

  padding: calc(var(--b) + var(--g));
  background-image:
    conic-gradient(from  90deg at top    var(--b) left  var(--b), #0000 25%, var(--c) 0),
    conic-gradient(from -90deg at bottom var(--b) right var(--b), #0000 25%, var(--c) 0);
  background-position:
    var(--_p, 0%) var(--_p, 0%),
    calc(100% - var(--_p, 0%)) calc(100% - var(--_p, 0%));
  background-size: var(--s) var(--s);
  background-repeat: no-repeat;
  transition: 
    background-position .3s var(--_i,.3s), 
    background-size .3s calc(.3s - var(--_i, .3s));
}
img:hover {
  background-size: calc(100% - var(--g)) calc(100% - var(--g));
  --_p: calc(var(--g) / 2);
  --_i: 0s;
}

Why do the --_i and --_p variables have an underscore in their name? The underscores are part of a naming convention I use to consider “internal” variables used to optimize the code. They are nothing special but I want to make a difference between the variables we adjust to control the frame (like --b, --c, etc.) and the ones I use to make the code shorter.

The code may look confusing and not easy to grasp but I wrote a three-part series where I detail such technique. I highly recommend reading at least the first article to understand how I reached the above code.

Here is an illustration to better understand the different values:

Showing the same image of two classic cars three times to illustrate the CSS variables used in the code.

The Frame Reveal

Let’s try another type of animation where we reveal the full frame on hover:

Cool, right? And you if you look closely, you will notice that the lines disappear in the opposite direction on mouse out which makes the effect even more fancy! I used a similar effect in a previous article.

But this time, instead of covering all the element, I cover only a small portion by defining a height to get something like this:

This is the top border of our frame. We repeat the same process on each side of the image and we have our hover effect:

img {
  --b: 10px; /* the border thickness*/
  --g: 5px; /* the gap on hover */
  --c: #8A9B0F; 

  padding: calc(var(--g) + var(--b));
  --_g: no-repeat linear-gradient(var(--c) 0 0);
  background: 
    var(--_g) var(--_i, 0%) 0,
    var(--_g) 100% var(--_i, 0%),
    var(--_g) calc(100% - var(--_i, 0%)) 100%,
    var(--_g) 0 calc(100% - var(--_i, 0%));
  background-size: var(--_i, 0%) var(--b),var(--b) var(--_i, 0%);
  transition: .4s, background-position 0s;
  cursor: pointer;
}
img:hover {
  --_i: 100%;
}

As you can see, I am applying the same gradient four times and each one has a different position to cover only one side at a time.

Another one? Let’s go!

This one looks a bit tricky and it indeed does require some imagination to understand how two conic gradients are pulling off this kind of magic. Here is a demo to illustrate one of the gradients:

The pseudo-element simulates the gradient. It’s initially out of sight and, on hover, we first change its position to get the top edge of the frame. Then we increase the height to get the right edge. The gradient shape is similar to the ones we used in the last section: two segments to cover two sides.

But why did I make the gradient’s width 200%? You’d think 100% would be enough, right?

100% should be enough but I won’t be able to move the gradient like I want if I keep its width equal to 100%. That’s another little quirk related to how background-position works. I cover this in a previous article. I also posted an answer over at Stack Overflow dealing with this. I know it’s a lot of reading, but it’s really worth your time.

Now that we have explained the logic for one gradient, the second one is easy because it’s doing exactly the same thing, but covering the left and bottom edges instead. All we have to do is to swap a few values and we are done:

img {
  --c: #8A9B0F; /* the border color */
  --b: 10px; /* the border thickness*/
  --g: 5px;  /* the gap */

  padding: calc(var(--g) + var(--b));
  --_g: #0000 25%, var(--c) 0;
  background: 
    conic-gradient(from 180deg at top    var(--b) right var(--b), var(--_g))
     var(--_i, 200%) 0 / 200% var(--_i, var(--b))  no-repeat,
    conic-gradient(            at bottom var(--b) left  var(--b), var(--_g))
     0 var(--_i, 200%) / var(--_i, var(--b)) 200%  no-repeat;
  transition: .3s, background-position .3s .3s;
  cursor: pointer;
}
img:hover {
  --_i: 100%;
  transition: .3s, background-size .3s .3s;
}

As you can see, both gradients are almost identical. I am simply swapping the values of the size and position.

The Frame Rotation

This time we are not going to draw a frame around our image, but rather adjust the look of an existing one.

You are probably asking how the heck I am able to transform a straight line into an angled line. No, the magic is different than that. That’s just the illusion we get after combining simple animations for four gradients.

Let’s see how the animation for the top gradient is made:

I am simply updating the position of a repeating gradient. Nothing fancy yet! Let’s do the same for the right side:

Are you starting to see the trick? Both gradients intersect at the corner to create the illusion where the straight line is changed to an angled one. Let’s remove the outline and hide the overflow to better see it:

Now, we add two more gradients to cover the remaining edges and we are done:

img {
  --g: 4px; /* the gap */
  --b: 12px; /* border thickness*/
  --c: #669706; /* the color */

  padding: calc(var(--g) + var(--b));
  --_c: #0000 0 25%, var(--c) 0 50%;
  --_g1: repeating-linear-gradient(90deg ,var(--_c)) repeat-x;
  --_g2: repeating-linear-gradient(180deg,var(--_c)) repeat-y;
  background:
    var(--_g1) var(--_p, 25%) 0, 
    var(--_g2) 0 var(--_p, 125%),
    var(--_g1) var(--_p, 125%) 100%, 
    var(--_g2) 100% var(--_p, 25%);
  background-size: 200% var(--b), var(--b) 200%;
  transition: .3s;
}
img:hover {
  --_p: 75%;
}

If we take this code and slightly adjust it, we can get another cool animation:

Can you figure out the logic in this example? That’s your homework! The code may look scary but it uses the same logic as the previous examples we looked at. Try to isolate each gradient and imagine how it animates.

Wrapping up

That’s a lot of gradients in one article!

It sure is and I warned you! But if the challenge is to decorate an image without an extra elements and pseudo-elements, we are left with only a few possibilities and gradients are the most powerful option.

Don’t worry if you are a bit lost in some of the explanations. I always recommend some of my old articles where I go into greater detail with some of the concepts we recycled for this challenge.

I am gonna leave with one last demo to hold you over until the next article in this series. This time, I am using radial-gradient() to create another funny hover effect. I’ll let you dissect the code to grok how it works. Ask me questions in the comments if you get stuck!

Fancy Image Decorations series

  • Single Element Magic — you are here
  • Masks and Advanced Hover Effects (coming October 21 )
  • Outlines and Complex Animations (coming October 28 )

Fancy Image Decorations: Single Element Magic originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

3D Perspective Glitch Hover Effect

The other day I stumbled upon this really nice hover effect on buōy’s website. It shows a tilted image that’s pixelated, with an awesome glitch effect. I totally love this and wanted to created my own CSS-only version, using the CSS glitch effect.

In the first demo, we rotate the image in 3D to look like the original effect.

The pixel effect is made with a CSS trick using image-rendering: pixelated. Read all about it in this article by Robin Rendle over at CSS-Tricks.

Every row shows a variation using a tilt effect in the second demo.

In the second demo, we use a bit of JavaScript to add a fancy tilt effect with Vanilla-tilt.js. Every row has a different version, so make sure to hover around.

I hope you enjoy this playful effect and find it useful!

CSS Grid and Custom Shapes, Part 2

Alright, so the last time we checked in, we were using CSS Grid and combining them with CSS clip-path and mask techniques to create grids with fancy shapes.

Here’s just one of the fantastic grids we made together:

Ready for the second round? We are still working with CSS Grid, clip-path, and mask, but by the end of this article, we’ll end up with different ways to arrange images on the grid, including some rad hover effects that make for an authentic, interactive experience to view pictures.

And guess what? We’re using the same markup that we used last time. Here’s that again:

<div class="gallery">
  <img src="..." alt="...">
  <img src="..." alt="...">
  <img src="..." alt="...">
  <img src="..." alt="...">
  <!-- as many times as we want -->
</div>

Like the previous article, we only need a container with images inside. Nothing more!

Nested Image Grid

Last time, our grids were, well, typical image grids. Other than the neat shapes we masked them with, they were pretty standard symmetrical grids as far as how we positioned the images inside.

Let’s try nesting an image in the center of the grid:

We start by setting a 2✕2 grid for four images:

.gallery {
  --s: 200px; /* controls the image size */
  --g: 10px; /* controls the gap between images */

  display: grid;
  gap: var(--g);
  grid-template-columns: repeat(2, auto);
}
.gallery > img {
  width: var(--s);
  aspect-ratio: 1;
  object-fit: cover;
}

Nothing complex yet. The next step is to cut the corner of our images to create the space for the nested image. I already have a detailed article on how to cut corners using clip-path and mask. You can also use my online generator to get the CSS for masking corners.

What we need here is to cut out the corners at an angle equal to 90deg. We can use the same conic-gradient technique from that article to do that:

.gallery > img {
   mask: conic-gradient(from var(--_a), #0000 90deg, #000 0);
}
.gallery > img:nth-child(1) { --_a: 90deg; }
.gallery > img:nth-child(2) { --_a: 180deg; }
.gallery > img:nth-child(3) { --_a: 0deg; }
.gallery > img:nth-child(4) { --_a:-90deg; }

We could use the clip-path method for cutting corners from that same article, but masking with gradients is more suitable here because we have the same configuration for all the images — all we need is a rotation (defined with the variable --_a) get the effect, so we’re masking from the inside instead of the outside edges.

Two by two grid of images with a white square stacked on top in the center.

Now we can place the nested image inside the masked space. First, let’s make sure we have a fifth image element in the HTML:

<div class="gallery">
  <img src="..." alt="...">
  <img src="..." alt="...">
  <img src="..." alt="...">
  <img src="..." alt="...">
  <img src="..." alt="...">
</div>

We are going to rely on the good ol’ absolute positioning to place it in there:

.gallery > img:nth-child(5) {
  position: absolute;
  inset: calc(50% - .5*var(--s));
  clip-path: inset(calc(var(--g) / 4));
}

The inset property allows us to place the image at the center using a single declaration. We know the size of the image (defined with the variable --s), and we know that the container’s size equals 100%. We do some math, and the distance from each edge should be equal to (100% - var(--s))/2.

Diagram of the widths needed to complete the design.

You might be wondering why we’re using clip-path at all here. We’re using it with the nested image to have a consistent gap. If we were to remove it, you would notice that we don’t have the same gap between all the images. This way, we’re cutting a little bit from the fifth image to get the proper spacing around it.

The complete code again:

.gallery {
  --s: 200px; /* controls the image size */
  --g: 10px;  /* controls the gap between images */
  
  display: grid;
  gap: var(--g);
  grid-template-columns: repeat(2, auto);
  position: relative;
}

.gallery > img {
  width: var(--s);
  aspect-ratio: 1;
  object-fit: cover;
  mask: conic-gradient(from var(--_a), #0000 90deg, #000 0);
}

.gallery > img:nth-child(1) {--_a: 90deg}
.gallery > img:nth-child(2) {--_a:180deg}
.gallery > img:nth-child(3) {--_a:  0deg}
.gallery > img:nth-child(4) {--_a:-90deg}
.gallery > img:nth-child(5) {
  position: absolute;
  inset: calc(50% - .5*var(--s));
  clip-path: inset(calc(var(--g) / 4));
}

Now, many of you might also be wondering: why all the complex stuff when we can place the last image on the top and add a border to it? That would hide the images underneath the nested image without a mask, right?

That’s true, and we will get the following:

No mask, no clip-path. Yes, the code is easy to understand, but there is a little drawback: the border color needs to be the same as the main background to make the illusion perfect. This little drawback is enough for me to make the code more complex in exchange for real transparency independent of the background. I am not saying a border approach is bad or wrong. I would recommend it in most cases where the background is known. But we are here to explore new stuff and, most important, build components that don’t depend on their environment.

Let’s try another shape this time:

This time, we made the nested image a circle instead of a square. That’s an easy task with border-radius But we need to use a circular cut-out for the other images. This time, though, we will rely on a radial-gradient() instead of a conic-gradient() to get that nice rounded look.

.gallery > img {
  mask: 
    radial-gradient(farthest-side at var(--_a),
      #0000 calc(50% + var(--g)/2), #000 calc(51% + var(--g)/2));
}
.gallery > img:nth-child(1) { --_a: calc(100% + var(--g)/2) calc(100% + var(--g)/2); }
.gallery > img:nth-child(2) { --_a: calc(0%   - var(--g)/2) calc(100% + var(--g)/2); }
.gallery > img:nth-child(3) { --_a: calc(100% + var(--g)/2) calc(0%   - var(--g)/2); }
.gallery > img:nth-child(4) { --_a: calc(0%   - var(--g)/2) calc(0%   - var(--g)/2); }

All the images use the same configuration as the previous example, but we update the center point each time.

Diagram showing the center values for each quadrant of the grid.

The above figure illustrates the center point for each circle. Still, in the actual code, you will notice that I am also accounting for the gap to ensure all the points are at the same position (the center of the grid) to get a continuous circle if we combine them.

Now that we have our layout let’s talk about the hover effect. In case you didn’t notice, a cool hover effect increases the size of the nested image and adjusts everything else accordingly. Increasing the size is a relatively easy task, but updating the gradient is more complicated since, by default, gradients cannot be animated. To overcome this, I will use a font-size hack to be able to animate the radial gradient.

If you check the code of the gradient, you can see that I am adding 1em:

mask: 
    radial-gradient(farthest-side at var(--_a),
      #0000 calc(50% + var(--g)/2 + 1em), #000 calc(51% + var(--g)/2 + 1em));

It’s known that em units are relative to the parent element’s font-size, so changing the font-size of the .gallery will also change the computed em value — this is the trick we are using. We are animating the font-size from a value of 0 to a given value and, as a result, the gradient is animated, making the cut-out part larger, following the size of the nested image that is getting bigger.

Here is the code that highlights the parts involved in the hover effect:

.gallery {
  --s: 200px; /* controls the image size */
  --g: 10px; /* controls the gaps between images */

  font-size: 0; /* initially we have 1em = 0 */
  transition: .5s;
}
/* we increase the cut-out by 1em */
.gallery > img {
  mask: 
    radial-gradient(farthest-side at var(--_a),
      #0000 calc(50% + var(--g)/2 + 1em), #000 calc(51% + var(--g)/2 + 1em));
}
/* we increase the size by 2em */
.gallery > img:nth-child(5) {
  width: calc(var(--s) + 2em);
}
/* on hover 1em = S/5 */
.gallery:hover {
  font-size: calc(var(--s) / 5);
}

The font-size trick is helpful if we want to animate gradients or other properties that cannot be animated. Custom properties defined with @property can solve such a problem, but support for it is still lacking at the time of writing.

I discovered the font-size trick from @SelenIT2 while trying to solve a challenge on Twitter.

Another shape? Let’s go!

This time we clipped the nested image into the shape of a rhombus. I’ll let you dissect the code as an exercise to figure out how we got here. You will notice that the structure is the same as in our examples. The only differences are how we’re using the gradient to create the shape. Dig in and learn!

Circular Image Grid

We can combine what we’ve learned here and in previous articles to make an even more exciting image grid. This time, let’s make all the images in our grid circular and, on hover, expand an image to reveal the entire thing as it covers the rest of the photos.

The HTML and CSS structure of the grid is nothing new from before, so let’s skip that part and focus instead on the circular shape and hover effect we want.

We are going to use clip-path and its circle() function to — you guessed it! — cut a circle out of the images.

Showing the two states of an image, the natural state on the left, and the hovered state on the right, including the clip-path values to create them.

That figure illustrates the clip-path used for the first image. The left side shows the image’s initial state, while the right shows the hovered state. You can use this online tool to play and visualize the clip-path values.

For the other images, we can update the center of the circle (70% 70%) to get the following code:

.gallery > img:hover {
  --_c: 50%; /* same as "50% at 50% 50%" */
}
.gallery > img:nth-child(1) {
  clip-path: circle(var(--_c, 55% at 70% 70%));
}
.gallery > img:nth-child(2) {
  clip-path: circle(var(--_c, 55% at 30% 70%));
}
.gallery > img:nth-child(3) {
  clip-path: circle(var(--_c, 55% at 70% 30%));
}
.gallery > img:nth-child(4) {
  clip-path: circle(var(--_c, 55% at 30% 30%));
}

Note how we are defining the clip-path values as a fallback inside var(). This way allows us to more easily update the value on hover by setting the value of the --_c variable. When using circle(), the default position of the center point is 50% 50%, so we get to omit that for more concise code. That’s why you see that we are only setting 50% instead of 50% at 50% 50%.

Then we increase the size of our image on hover to the overall size of the grid so we can cover the other images. We also ensure the z-index has a higher value on the hovered image, so it is the top one in our stacking context.

.gallery {
  --s: 200px; /* controls the image size */
  --g: 8px;   /* controls the gap between images */

  display: grid;
  grid: auto-flow var(--s) / repeat(2, var(--s));
  gap: var(--g);
}

.gallery > img {
  width: 100%; 
  aspect-ratio: 1;
  cursor: pointer;
  z-index: 0;
  transition: .25s, z-index 0s .25s;
}
.gallery > img:hover {
  --_c: 50%; /* change the center point on hover */
  width: calc(200% + var(--g));
  z-index: 1;
  transition: .4s, z-index 0s;
}

.gallery > img:nth-child(1){
  clip-path: circle(var(--_c, 55% at 70% 70%));
  place-self: start;
}
.gallery > img:nth-child(2){
  clip-path: circle(var(--_c, 55% at 30% 70%));
  place-self: start end;
}
.gallery > img:nth-child(3){
  clip-path: circle(var(--_c, 55% at 70% 30%));
  place-self: end start;
}
.gallery > img:nth-child(4){
  clip-path: circle(var(--_c, 55% at 30% 30%));
  place-self: end;
}

What’s going on with the place-self property? Why do we need it and why does each image have a specific value?

Do you remember the issue we had in the previous article when creating the grid of puzzle pieces? We increased the size of the images to create an overflow, but the overflow of some images was incorrect. We fixed them using the place-self property.

Same issue here. We are increasing the size of the images so each one overflows its grid cells. But if we do nothing, all of them will overflow on the right and bottom sides of the grid. What we need is:

  1. the first image to overflow the bottom-right edge (the default behavior),
  2. the second image to overflow the bottom-left edge,
  3. the third image to overflow the top-right edge, and
  4. the fourth image to overflow the top-left edge.

To get that, we need to place each image correctly using the place-self property.

Diagram showing the place-self property values for each quadrant of the grid.

In case you are not familiar with place-self, it’s the shorthand for justify-self and align-self to place the element horizontally and vertically. When it takes one value, both alignments use that same value.

Expanding Image Panels

In a previous article, I created a cool zoom effect that applies to a grid of images where we can control everything: number of rows, number of columns, sizes, scale factor, etc.

A particular case was the classic expanding panels, where we only have one row and a full-width container.

We will take this example and combine it with shapes!

Before we continue, I highly recommend reading my other article to understand how the tricks we’re about to cover work. Check that out, and we’ll continue here to focus on creating the panel shapes.

First, let’s start by simplifying the code and removing some variables

We only need one row and the number of columns should adjust based on the number of images. That means we no longer need variables for the number of rows (--n) and columns (--m ) but we need to use grid-auto-flow: column, allowing the grid to auto-generate columns as we add new images. We will consider a fixed height for our container; by default, it will be full-width.

Let’s clip the images into a slanted shape:

A headshot of a calm red wolf looking downward with vertices overlayed showing the clip-path property points.
clip-path: polygon(S 0%, 100% 0%, (100% - S) 100%, 0% 100%);

Once again, each image is contained in its grid cell, so there’s more space between the images than we’d like:

A six-panel grid of slanted images of various wild animals showing the grid lines and gaps.

We need to increase the width of the images to create an overlap. We replace min-width: 100% with min-width: calc(100% + var(--s)), where --s is a new variable that controls the shape.

Now we need to fix the first and last images, so they sort of bleed off the page without gaps. In other words, we can remove the slant from the left side of the first image and the slant from the right side of the last image. We need a new clip-path specifically for those two images.

We also need to rectify the overflow. By default, all the images will overflow on both sides, but for the first one, we need an overflow on the right side while we need a left overflow for the last image.

.gallery > img:first-child {
  min-width: calc(100% + var(--s)/2);
  place-self: start;
  clip-path: polygon(0 0,100% 0,calc(100% - var(--s)) 100%,0 100%);
}
.gallery > img:last-child {
  min-width: calc(100% + var(--s)/2);
  place-self: end;
  clip-path: polygon(var(--s) 0,100% 0,100% 100%,0 100%);
}

The final result is a nice expanding panel of slanted images!

We can add as many images as you want, and the grid will adjust automatically. Plus, we only need to control one value to control the shape!

We could have made this same layout with flexbox since we are dealing with a single row of elements. Here is my implementation.

Sure, slanted images are cool, but what about a zig-zag pattern? I already teased this one at the end of the last article.

All I’m doing here is replacing clip-path with mask… and guess what? I already have a detailed article on creating that zig-zag shape — not to mention an online generator to get the code. See how all everything comes together?

The trickiest part here is to make sure the zig-zags are perfectly aligned, and for this, we need to add an offset for every :nth-child(odd) image element.

.gallery > img {
  mask: 
    conic-gradient(from -135deg at right, #0000, #000 1deg 89deg, #0000 90deg) 
      100% calc(50% + var(--_p, 0%))/51% calc(2*var(--s)) repeat-y,
    conic-gradient(from   45deg at left,  #0000, #000 1deg 89deg, #0000 90deg) 
      0%   calc(50% + var(--_p, 0%))/51% calc(2*var(--s)) repeat-y;
}
/* we add an offset to the odd elements */
.gallery > img:nth-child(odd) {
  --_p: var(--s);
}
.gallery > img:first-child {
  mask: 
    conic-gradient(from -135deg at right, #0000, #000 1deg 89deg, #0000 90deg) 
      0 calc(50% + var(--_p, 0%))/100% calc(2*var(--s));
}
.gallery > img:last-child {
  mask: 
    conic-gradient(from 45deg at left, #0000, #000 1deg 89deg, #0000 90deg) 
      0 calc(50% + var(--_p, 0%)) /100% calc(2*var(--s));
}

Note the use of the --_p variable, which will fall back to 0% but will be equal to --_s for the odd images.

Here is a demo that illustrates the issue. Hover to see how the offset — defined by --_p — is fixing the alignment.

Also, notice how we use a different mask for the first and last image as we did in the previous example. We only need a zig-zag on the right side of the first image and the left side of the last image.

And why not rounded sides? Let’s do it!

I know that the code may look scary and tough to understand, but all that’s going on is a combination of different tricks we’ve covered in this and other articles I’ve already shared. In this case, I use the same code structure as the zig-zag and the slanted shapes. Compare it with those examples, and you will find no difference! Those are the same tricks in my previous article about the zoom effect. Then, I am using my other writing and my online generator to get the code for the mask that creates those rounded shapes.

If you recall what we did for the zig-zag, we had used the same mask for all the images but then had to add an offset to the odd images to create a perfect overlap. In this case, we need a different mask for the odd-numbered images.

The first mask:

mask: 
  linear-gradient(-90deg,#0000 calc(2*var(--s)),#000 0) var(--s),
  radial-gradient(var(--s),#000 98%,#0000) 50% / calc(2*var(--s)) calc(1.8*var(--s)) space repeat;

The second one:

mask:
  radial-gradient(calc(var(--s) + var(--g)) at calc(var(--s) + var(--g)) 50%,#0000 98% ,#000) 
  calc(50% - var(--s) - var(--g)) / 100% calc(1.8*var(--s))

The only effort I did here is update the second mask to include the gap variable (--g) to create that space between the images.

The final touch is to fix the first and last image. Like all the previous examples, the first image needs a straight left edge while the last one needs a straight right edge.

For the first image, we always know the mask it needs to have, which is the following:

.gallery > img:first-child {
  mask: 
    radial-gradient(calc(var(--s) + var(--g)) at right, #0000 98%, #000) 50% / 100% calc(1.8 * var(--s));
}
A brown bear headshot with a wavy pattern for the right border.

For the last image, it depends on the number of elements, so it matters if that element is :nth-child(odd) or :nth-child(even).

The complete grid of wild animal photos with all of the correct borders and gaps between images.
.gallery > img:last-child:nth-child(even) {
  mask: 
    linear-gradient(to right,#0000 var(--s),#000 0),
    radial-gradient(var(--s),#000 98%,#0000) left / calc(2*var(--s)) calc(1.8*var(--s)) repeat-y
}
A single-row grid of three wild animal photos with wavy borders where the last image is an odd-numbered element.
.gallery > img:last-child:nth-child(odd) {
  mask: 
    radial-gradient(calc(var(--s) + var(--g)) at left,#0000 98%,#000) 50% / 100% calc(1.8*var(--s))
}

That’s all! Three different layouts but the same CSS tricks each time:

  • the code structure to create the zoom effect
  • a mask or clip-path to create the shapes
  • a separate configuration for the odd elements in some cases to make sure we have a perfect overlap
  • a specific configuration for the first and last image to keep the shape on only one side.

And here is a big demo with all of them together. All you need is to add a class to activate the layout you want to see.

And here is the one with the Flexbox implementation

Wrapping up

Oof, we are done! I know there are many CSS tricks and examples between this article and the last one, not to mention all of the other tricks I’ve referenced here from other articles I’ve written. It took me time to put everything together, and you don’t have to understand everything at once. One reading will give you a good overview of all the layouts, but you may need to read the article more than once and focus on each example to grasp all the tricks.

Did you notice that we didn’t touch the HTML at all other than perhaps the number of images in the markup? All the layouts we made share the same HTML code, which is nothing but a list of images.

Before I end, I will leave you with one last example. It’s a “versus” between two anime characters with a cool hover effect.

What about you? Can you create something based on what you have learned? It doesn’t need to be complex — imagine something cool or funny like I did with that anime matchup. It can be a good exercise for you, and we may end with an excellent collection in the comment section.


CSS Grid and Custom Shapes, Part 2 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Zooming Images in a Grid Layout

Creating a grid of images is easy, thanks to CSS Grid. But making the grid do fancy things after the images have been placed can be tricky to pull off.

Say you want to add some fancy hover effect to the images where they grow and zoom beyond the rows and columns where they sit? We can do that!

Cool, right? If you check the code, you won’t find any JavaScript, complex selectors, or even magic numbers. And this is only one example among many we will explore!

Building the grid

The HTML code to create the grid is as simple as a list of images within a container. We don’t need more than that.

<div class="gallery">
  <img>
  <img>
  <img>
  <!-- etc. -->
</div>

For the CSS, we first start by setting the grid using the following:

.gallery {
  --s: 150px; /* controls the size */
  --g: 10px;  /* controls the gap */

  display: grid;
  gap: var(--g);
  width: calc(3*var(--s) + 2*var(--g)); /* 3 times the size plus 2 times the gap */
  aspect-ratio: 1;
  grid-template-columns: repeat(3, auto);
}

In short, we have two variables, one that controls the size of the images and one that sets the size of the gap between images. aspect-ratio helps keep things in proportion.

You might be wondering why we are only defining three columns but no rows. No, I didn’t forget the rows — we just don’t need to explicitly set them. CSS Grid is capable of automatically placing items on implicit rows and columns, meaning we get as many rows as needed to any number of images we throw at it. We can explicitly define the rows instead but we need to add grid-auto-flow: column to make sure the browser will create the needed columns for us.

Here is an example to illustrate both cases. The difference is that one flows in a row direction an the other in a column direction.

Check out this other article I wrote for more about the implicit grids and the auto-placement algorithm.

Now that we have our grid, it’s time to style the images:

.gallery > img {
  width: 0;
  height: 0;
  min-height: 100%;
  min-width: 100%;
  object-fit: cover;
}

The hover effect we’re making relies on this CSS. It probably looks weird to you that we’re making images that have both no width or height but have a minimum width and height of 100%. But you will see that it’s a pretty neat trick for what we are trying to achieve.

What I’m doing here is telling the browser that the images need to have 0 width and height but also need to have a minimum height equal to 100%… but 100% of what? When using percentages, the value is relative to something else. In this case, our image is placed inside a grid cell and we need to know that size to know what’s 100% is relative to.

The browser will first ignore min-height: 100% to calculate the size of the grid cells, but it will use the height: 0 in its calculation. That means our images will not contribute to the size of the grid cells… because they technically have no physical size. This will result in three equal columns and rows that are based on the size of the grid (which we defined on the .gallery’s width and aspect-ratio). The height of each grid cell is nothing but the variable --s we defined (same for the width).

Now that we have the dimensions of our grid’s cells, the browser will use it with min-height: 100% (and min-width: 100%) which will force the images to completely fill the space of each grid cell. The whole thing may look a bit confusing but the main idea is to make sure that the grid defines the size of the images rather than the other way around. I don’t want the image to define the size of the grid and you will understand why after adding the hover effect.

Creating the hover effect

What we need to do is increase the scale of the images when they’re hovered. We can do that by adjusting an image’s width and height on :hover:

.gallery {
  --f: 1.5; /* controls the scale factor */
}

.gallery img:hover{
  width:  calc(var(--s) * var(--f));
  height: calc(var(--s) * var(--f));
}

I added a new custom variable, --f, to the mix as a scale factor to control the size on hover. Notice how I’m multiplying the size variable, --s, by it to calculate the new image size.

But you said that the image size needs to be 0. What is going on? I am lost…

What I said is still true but I am making an exception for the hovered image. I am telling the browser that only one image will have a size that’s not equal to zero — so it will contribute to the dimension of the grid — while all the others remain equal to 0.

The left side shows the grid in its natural state without any hovered images, which is what the right side is showing. All the grid cells on the left side are equal in size since all the images have no physical dimensions.

On the right side, the second image in the first row is hovered, which gives it dimensions that affect the grid cell’s size. The browser will make that specific grid cell bigger on hover, which contributes to the overall size. And since the size of the whole grid is set (because we set a fixed width on the .gallery), the other grid cells will logically respond by becoming smaller in order to keep the .gallery‘s overall size in tact.

That’s our zoom effect in action! By increasing the size of only one image we affect the whole grid configuration, and we said before that the grid defines the size of the images so that each image stretches inside its grid cell to fill all the space.

To this, we add a touch of transition and use object-fit to avoid image distortion and the illusion is perfect!

I know that the logic behind the trick is not easy to grasp. Don’t worry if you don’t fully understand it. The most important is to understand the structure of the code used and how to modify it to get more variations. That’s what we will do next!

Adding more images

We created a 3×3 grid to explain the main trick, but you have probably guessed that we there’d no need to stop there. We can make the number of columns and rows variables and add as many images as we want.

.gallery {
  --n: 3; /* number of rows*/
  --m: 4; /* number of columns */
  --s: 150px; /* control the size */
  --g: 10px;  /* control the gap */
  --f: 1.5;   /* control the scale factor */

  display: grid;
  gap: var(--g);
  width:  calc(var(--m)*var(--s) + (var(--m) - 1)*var(--g));
  height: calc(var(--n)*var(--s) + (var(--n) - 1)*var(--g));
  grid-template-columns: repeat(var(--m),auto);
}

We have two new variables for the number of rows and columns. Then we simply define the width and height of our grid using them. Same for grid-template-columns which uses the --m variable. And just like before, we don’t need to explicitly define the rows since the CSS Grid’s auto-placement feature will do the job for us no matter how many image elements we’re using.

Why not different values for the width and height? We can do that:

.gallery {
  --n: 3; /* number of rows*/
  --m: 4; /* number of columns */
  --h: 120px; /* control the height */
  --w: 150px; /* control the width */
  --g: 10px;  /* control the gap */
  --f: 1.5;   /* control the scale factor */

  display: grid;
  gap: var(--g);
  width:  calc(var(--m)*var(--w) + (var(--m) - 1)*var(--g));
  height: calc(var(--n)*var(--h) + (var(--n) - 1)*var(--g));
  grid-template-columns: repeat(var(--m),auto);
}

.gallery img:hover{
  width:  calc(var(--w)*var(--f));
  height: calc(var(--h)*var(--f));
}

We replace --s with two variables, one for the width, --w, and another one for the height, --h. Then we adjust everything else accordingly.

So, we started with a grid with a fixed size and number of elements, but then we made a new set of variables to get any configuration we want. All we have to do is to add as many images as we want and adjust the CSS variables accordingly. The combinations are limitless!

What about a full-screen version? Yes, that’s also possible. All we need is to know what values we need to assign to our variables. If we want N rows of images and we want our grid to be full screen, we first need to solve for a height of 100vh:

var(--n) * var(--h) + (var(--n) - 1) * var(--g) = 100vh

Same logic for the width, but using vw instead of vh:

var(--m) * var(--w) + (var(--m) - 1) * var(--g) = 100vw

We do the math to get:

--w: (100vw - (var(--m) - 1) * var(--g)) / var(--m)
--h: (100vh - (var(--n) - 1) * var(--g)) / var(--n)

Done!

It’s the same exact HTML but with some updated variables that change the grid’s sizing and behavior.

Note that I have omitted the formula we previously set on the .gallery‘s width and height and replaced them with 100vw and 100vh, respectively. The formula will give us the same result but since we know what value we want, we can ditch all that added complexity.

We can also simplify the --h and --w by removing the gap from the equation in favor of this:

--h: calc(100vh / var(--n)); /* Viewport height divided by number of rows */
--w: calc(100vw / var(--m)); /* Viewport width divided by number of columns */

This will make the hovered image grow a bit more than the previous example, but it is no big deal since we can control the scale with the --f variable we’re using as a multiplier.

And since the variables are used in one place we can still simplify the code by removing them altogether:

It’s important to note this optimization applies only to the full-screen example and not to the examples we’ve covered. This example is a particular case where we can make the code lighter by removing some of the complex calculation work we needed in the other examples.

We actually have everything we need to create the popular pattern of expanding panels:

Let’s dig even deeper

Did you notice that our scale factor can be less than 1? We can define the size of the hovered image to be smaller than --h or --w but the image gets bigger on hover.

The initial grid cell size is equal to --w and --h, so why do a smaller values make the grid cell bigger? Shouldn’t the cell get smaller, or at least maintain its initial size? And what is the final size of the grid cell?

We need to dig deeper into how the CSS Grid algorithm calculates the size of the grid cells. And this is involves understanding CSS Grid’s default stretch alignment.

Here’s an example to understand the logic.

On the left side of the demo, I defined a two-column with auto width. We get the intuitive result: two equal columns (and two equal grid cells). But the grid I set up on the right side of the demo, where I am updating the alignment using place-content: start, appears to have nothing.

DevTools helps show us what’s really happening in both cases:

In the second grid, we have two columns, but their widths equal zero, so we get two grid cells that are collapsed at the top-left corner of the grid container. This is not a bug but the logical result of the grid’s alignment. When we size a column (or row) with auto, it means that its content dictates its size — but we have an empty div with no content to make room for.

But since stretch is the default alignment and we have enough space inside our grid, the browser will stretch both grid cells equally to cover all that area. That’s how the grid on the left winds up with two equal columns.

From the specification:

Note that certain values of justify-content and align-content can cause the tracks to be spaced apart (space-around, space-between, space-evenly) or to be resized (stretch).

Note the “to be resized” which is the key here. In the last example, I used place-content which is the shorthand for justify-content and align-content

And this is buried somewhere in the Grid Sizing algorithm specs:

This step expands tracks that have an auto max track sizing function by dividing any remaining positive, definite free space equally amongst them. If the free space is indefinite, but the grid container has a definite min-width/height, use that size to calculate the free space for this step instead.

“Equally” explains why we wind up with equal grid cells, but it applies to “the free space” which is very important.

Let’s take the previous example and add content to one of the divs:

We added a square 50px image. Here’s an illustration of how each grid in our example responds to that image:

In the first case, we can see that the first cell (in red) is bigger than the second one (in blue). In the second case, the size of the first cell changes to fit the physical size of the image while the second cell remains with no dimensions. The free space is divided equally, but the first cell has more content inside which makes it bigger.

This is the math to figure out our free space:

(grid width) - (gap) - (image width) = (free space)
200px - 5px - 50px = 145px 

Divided by two — the number of columns — we get a width of 72.5px for each column. But we add the size of the image, 50px, to the first column which leaves us with one column at 122.5px and the second one equal to 72.5px.

The same logic applies to our grid of images. All the images have a size equal to 0 (no content) while the hovered image contributes to size — even if it’s just 1px — making its grid cell bigger than the others. For this reason, the scale factor can be any value bigger than 0 even decimals between 0 and 1.

To get the final width of the grid cells, we do the same calculation to get the following:

(container width) - (sum of all gaps) - (hovered image width) = (free space)

The width of container is defined by:

var(--m)*var(--w) + (var(--m) - 1)*var(--g)

…and all the gaps are equal to:

(var(--m) - 1)*var(--g)

…and for the hovered image we have:

var(--w)*var(--f)

We can calculate all of that with our variables:

var(--m)*var(--w) - var(--w)*var(--f) = var(--w)*(var(--m) - var(--f))

The number of columns is defined by --m ,so we divide that free space equally to get:

var(--w)*(var(--m) - var(--f))/var(--m)

…which gives us the size of the non-hovered images. For hovered images, we have this:

var(--w)*(var(--m) - var(--f))/var(--m) + var(--w)*var(--f)
var(--w)*((var(--m) - var(--f))/var(--m) + var(--f))

If we want to control the final size of the hovered image, we consider the above formula to get the exact size we want. If, for example, we want the image to be twice as big:

(var(--m) - var(--f))/var(--m) + var(--f) = 2

So, the value of our scale multiplier, --f, needs to be equal to:

var(--m)/(var(--m) - 1)

For three columns we will have 3/2 = 1.5 and that’s the scale factor I used in the first demo of this article because I wanted to make the image twice as big on hover!

The same logic applies to the height calculation and in case we want to control both of them independently we will need to consider two scale factors to make sure we have a specific width and height on hover.

.gallery {
  /* same as before */
   --fw: 1.5; /* controls the scale factor for the width */
   --fh: 1.2; /* controls the scale factor for the height */

  /* same as before */
}

.gallery img:hover{
  width:  calc(var(--w)*var(--fw));
  height: calc(var(--h)*var(--fh));
}

Now, you know all the secrets to create any kind of image grid with a cool hover effect while also having control of the sizing you want using the math we just covered.

Wrapping up

In my last article, we created a complex-looking grid with a few lines of CSS that put CSS Grid’s implicit grid and auto-placement features to use. In this article, we relied on some CSS Grid sizing trickery to create a fancy grid of images that zoom on hover and cause the grid to adjust accordingly. All of this with a simplified code that is easy to adjust using CSS variables!

In the next article, we will play with shapes! We will combine CSS grid with mask and clip-path to get fancy grid of images.


Zooming Images in a Grid Layout originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Cool CSS Hover Effects That Use Background Clipping, Masks, and 3D

We’ve walked through a series of posts now about interesting approaches to CSS hover effects. We started with a bunch of examples that use CSS background properties, then moved on to the text-shadow property where we technically didn’t use any shadows. We also combined them with CSS variables and calc() to optimize the code and make it easy to manage.

In this article, we will build off those two articles to create even more complex CSS hover animations. We’re talking about background clipping, CSS masks, and even getting our feet wet with 3D perspectives. In other words, we are going to explore advanced techniques this time around and push the limits of what CSS can do with hover effects!

Cool Hover Effects series:

  1. Cool Hover Effects That Use Background Properties
  2. Cool Hover Effects That Use CSS Text Shadow
  3. Cool Hover Effects That Use Background Clipping, Masks, and 3D (you are here!)

Here’s just a taste of what we’re making:

Hover effects using background-clip

Let’s talk about background-clip. This CSS property accepts a text keyword value that allows us to apply gradients to the text of an element instead of the actual background.

So, for example, we can change the color of the text on hover as we would using the color property, but this way we animate the color change:

All I did was add background-clip: text to the element and transition the background-position. Doesn’t have to be more complicated than that!

But we can do better if we combine multiple gradients with different background clipping values.

In that example, I use two different gradients and two values with background-clip. The first background gradient is clipped to the text (thanks to the text value) to set the color on hover, while the second background gradient creates the bottom underline (thanks to the padding-box value). Everything else is straight up copied from the work we did in the first article of this series.

How about a hover effect where the bar slides from top to bottom in a way that looks like the text is scanned, then colored in:

This time I changed the size of the first gradient to create the line. Then I slide it with the other gradient that update the text color to create the illusion! You can visualize what’s happening in this pen:

We’ve only scratched the surface of what we can do with our background-clipping powers! However, this technique is likely something you’d want to avoid using in production, as Firefox is known to have a lot of reported bugs related to background-clip. Safari has support issues as well. That leaves only Chrome with solid support for this stuff, so maybe have it open as we continue.

Let’s move on to another hover effect using background-clip:

You’re probably thinking this one looks super easy compared to what we’ve just covered — and you are right, there’s nothing fancy here. All I am doing is sliding one gradient while increasing the size of another one.

But we’re here to look at advanced hover effects, right? Let’s change it up a bit so the animation is different when the mouse cursor leaves the element. Same hover effect, but a different ending to the animation:

Cool right? let’s dissect the code:

.hover {
  --c: #1095c1; /* the color */

  color: #0000;
  background: 
    linear-gradient(90deg, #fff 50%, var(--c) 0) calc(100% - var(--_p, 0%)) / 200%, 
    linear-gradient(var(--c) 0 0) 0% 100% / var(--_p, 0%) no-repeat,
    var(--_c, #0000);
  -webkit-background-clip: text, padding-box, padding-box;
          background-clip: text, padding-box, padding-box;
  transition: 0s, color .5s, background-color .5s;
}
.hover:hover {
  color: #fff;
  --_c: var(--c);
  --_p: 100%;
  transition: 0.5s, color 0s .5s, background-color 0s .5s;
}

We have three background layers — two gradients and the background-color defined using --_c variable which is initially set to transparent (#0000). On hover, we change the color to white and the --_c variable to the main color (--c).

Here’s what is happening on that transition: First, we apply a transition to everything but we delay the color and background-color by 0.5s to create the sliding effect. Right after that, we change the color and the background-color. You might notice no visual changes because the text is already white (thanks to the first gradient) and the background is already set to the main color (thanks to the second gradient).

Then, on mouse out, we apply an instant change to everything (notice the 0s delay), except for the color and background-color that have a transition. This means that we put all the gradients back to their initial states. Again, you will probably see no visual changes because the text color and background-color already changed on hover.

Lastly, we apply the fading to color and a background-color to create the mouse-out part of the animation. I know, it may be tricky to grasp but you can better visualize the trick by using different colors:

Hover the above a lot of times and you will see the properties that are animating on hover and the ones animating on mouse out. You can then understand how we reached two different animations for the same hover effect.

Let’s not forget the DRY switching technique we used in the previous articles of this series to help reduce the amount of code by using only one variable for the switch:

.hover {
  --c: 16 149 193; /* the color using the RGB format */

  color: rgb(255 255 255 / var(--_i, 0));
  background:
    /* Gradient #1 */
    linear-gradient(90deg, #fff 50%, rgb(var(--c)) 0) calc(100% - var(--_i, 0) * 100%) / 200%,
    /* Gradient #2 */
    linear-gradient(rgb(var(--c)) 0 0) 0% 100% / calc(var(--_i, 0) * 100%) no-repeat,
    /* Background Color */
    rgb(var(--c)/ var(--_i, 0));
  -webkit-background-clip: text, padding-box, padding-box;
          background-clip: text, padding-box, padding-box;
  --_t: calc(var(--_i,0)*.5s);
  transition: 
    var(--_t),
    color calc(.5s - var(--_t)) var(--_t),
    background-color calc(.5s - var(--_t)) var(--_t);
}
.hover:hover {
  --_i: 1;
}

If you’re wondering why I reached for the RGB syntax for the main color, it’s because I needed to play with the alpha transparency. I am also using the variable --_t to reduce a redundant calculation used in the transition property.

Before we move to the next part here are more examples of hover effects I did a while ago that rely on background-clip. It would be too long to detail each one but with what we have learned so far you can easily understand the code. It can be a good inspiration to try some of them alone without looking at the code.

I know, I know. These are crazy and uncommon hover effects and I realize they are too much in most situations. But this is how to practice and learn CSS. Remember, we pushing the limits of CSS hover effects. The hover effect may be a novelty, but we’re learning new techniques along the way that can most certainly be used for other things.

Hover effects using CSS mask

Guess what? The CSS mask property uses gradients the same way the background property does, so you will see that what we’re making next is pretty straightforward.

Let’s start by building a fancy underline.

I’m using background to create a zig-zag bottom border in that demo. If I wanted to apply an animation to that underline, it would be tedious to do it using background properties alone.

Enter CSS mask.

The code may look strange but the logic is still the same as we did with all the previous background animations. The mask is composed of two gradients. The first gradient is defined with an opaque color that covers the content area (thanks to the content-box value). That first gradient makes the text visible and hides the bottom zig-zag border. content-box is the mask-clip value which behaves the same as background-clip

linear-gradient(#000 0 0) content-box

The second gradient will cover the whole area (thanks to padding-box). This one has a width that’s defined using the --_p variable, and it will be placed on the left side of the element.

linear-gradient(#000 0 0) 0 / var(--_p, 0%) padding-box

Now, all we have to do is to change the value of --_p on hover to create a sliding effect for the second gradient and reveal the underline.

.hover:hover {
  --_p: 100%;
  color: var(--c);
}

The following demo uses with the mask layers as backgrounds to better see the trick taking place. Imagine that the green and red parts are the visible parts of the element while everything else is transparent. That’s what the mask will do if we use the same gradients with it.

With such a trick, we can easily create a lot of variation by simply using a different gradient configuration with the mask property:

Each example in that demo uses a slightly different gradient configuration for the mask. Notice, too, the separation in the code between the background configuration and the mask configuration. They can be managed and maintained independently.

Let’s change the background configuration by replacing the zig-zag underline with a wavy underline instead:

Another collection of hover effects! I kept all the mask configurations and changed the background to create a different shape. Now, you can understand how I was able to reach 400 hover effects without pseudo-elements — and we can still have more!

Like, why not something like this:

Here’s a challenge for you: The border in that last demo is a gradient using the mask property to reveal it. Can you figure out the logic behind the animation? It may look complex at first glance, but it’s super similar to the logic we’ve looked at for most of the other hover effects that rely on gradients. Post your explanation in the comments!

Hover effects in 3D

You may think it’s impossible to create a 3D effect with a single element (and without resorting to pseudo-elements!) but CSS has a way to make it happen.

What you’re seeing there isn’t a real 3D effect, but rather a perfect illusion of 3D in the 2D space that combines the CSS background, clip-path, and transform properties.

Breakdown of the CSS hover effect in four stages.
The trick may look like we’re interacting with a 3D element, but we’re merely using 2D tactics to draw a 3D box

The first thing we do is to define our variables:

--c: #1095c1; /* color */
--b: .1em; /* border length */
--d: 20px; /* cube depth */

Then we create a transparent border with widths that use the above variables:

--_s: calc(var(--d) + var(--b));
color: var(--c);
border: solid #0000; /* fourth value sets the color's alpha */
border-width: var(--b) var(--b) var(--_s) var(--_s);

The top and right sides of the element both need to equal the --b value while the bottom and left sides need to equal to the sum of --b and --d (which is the --_s variable).

For the second part of the trick, we need to define one gradient that covers all the border areas we previously defined. A conic-gradient will work for that:

background: conic-gradient(
  at left var(--_s) bottom var(--_s),
  #0000 90deg,var(--c) 0
 ) 
 0 100% / calc(100% - var(--b)) calc(100% - var(--b)) border-box;
Diagram of the sizing used for the hover effect.

We add another gradient for the third part of the trick. This one will use two semi-transparent white color values that overlap the first previous gradient to create different shades of the main color, giving us the illusion of shading and depth.

conic-gradient(
  at left var(--d) bottom var(--d),
  #0000 90deg,
  rgb(255 255 255 / 0.3) 0 225deg,
  rgb(255 255 255 / 0.6) 0
) border-box
Showing the angles used to create the hover effect.

The last step is to apply a CSS clip-path to cut the corners for that long shadow sorta feel:

clip-path: polygon(
  0% var(--d), 
  var(--d) 0%, 
  100% 0%, 
  100% calc(100% - var(--d)), 
  calc(100% - var(--d)) 100%, 
  0% 100%
)
Showing the coordinate points of the three-dimensional cube used in the CSS hover effect.

That’s all! We just made a 3D rectangle with nothing but two gradients and a clip-path that we can easily adjust using CSS variables. Now, all we have to do is to animate it!

Notice the coordinates from the previous figure (indicated in red). Let’s update those to create the animation:

clip-path: polygon(
  0% var(--d), /* reverses var(--d) 0% */
  var(--d) 0%, 
  100% 0%, 
  100% calc(100% - var(--d)), 
  calc(100% - var(--d)) 100%, /* reverses 100% calc(100% - var(--d)) */ 
  0% 100% /* reverses var(--d) calc(100% - var(--d)) */
)

The trick is to hide the bottom and left parts of the element so all that’s left is a rectangular element with no depth whatsoever.

This pen isolates the clip-path portion of the animation to see what it’s doing:

The final touch is to move the element in the opposite direction using translate — and the illusion is perfect! Here’s the effect using different custom property values for varying depths:

The second hover effect follows the same structure. All I did is to update a few values to create a top left movement instead of a top right one.

Combining effects!

The awesome thing about everything we’ve covered is that they all complement each other. Here is an example where I am adding the text-shadow effect from the second article in the series to the background animation technique from the first article while using the 3D trick we just covered:

The actual code might be confusing at first, but go ahead and dissect it a little further — you’ll notice that it’s merely a combination of those three different effects, pretty much smushed together.

Let me finish this article with a last hover effect where I am combining background, clip-path, and a dash of perspective to simulate another 3D effect:

I applied the same effect to images and the result was quite good for simulating 3D with a single element:

Want a closer look at how that last demo works? I wrote something up on it.

Wrapping up

Oof, we are done! I know, it’s a lot of tricky CSS but (1) we’re on the right website for that kind of thing, and (2) the goal is to push our understanding of different CSS properties to new levels by allowing them to interact with one another.

You may be asking what the next step is from here now that we’re closing out this little series of advanced CSS hover effects. I’d say the next step is to take all that we learned and apply them to other elements, like buttons, menu items, links, etc. We kept things rather simple as far as limiting our tricks to a heading element for that exact reason; the actual element doesn’t matter. Take the concepts and run with them to create, experiment with, and learn new things!


Cool CSS Hover Effects That Use Background Clipping, Masks, and 3D originally published on CSS-Tricks. You should get the newsletter.

Cool Hover Effects That Use CSS Text Shadow

In my last article we saw how CSS background properties allow us to create cool hover effects. This time, we will focus on the CSS text-shadow property to explore even more interesting hovers. You are probably wondering how adding shadow to text can possibly give us a cool effect, but here’s the catch: we’re not actually going to make any shadows for these text hover effects.

text-shadow but no text shadows?

Let me clear the confusion by showing the hover effects we are going to build in the following demo:

Without looking at the code many of you will, intuitively, think that for each hover effect we are duplicating the text and then independently animating them. Now, if you check the code you will see that none of the text is actually duplicated in the HTML. And did you notice that there is no use of content: "text" in the CSS?

The text layers are completely made with text-shadow!

Hover effect #1

Let’s pick apart the CSS:

.hover-1 {
  line-height: 1.2em;
  color: #0000;
  text-shadow: 
    0 0 #000, 
    0 1.2em #1095c1;
  overflow: hidden;
  transition: .3s;
}
.hover-1:hover {
  text-shadow: 
    0 -1.2em #000, 
    0 0 #1095c1;
}

The first thing to notice is that I am making the color of the actual text transparent (using #0000) in order to hide it. After that, I am using text-shadow to create two shadows where I am defining only two length values for each one. That means there’s no blur radius, making for a sharp, crisp shadow that effectively produces a copy of the text with the specified color.

That’s why I was able to claim in the introduction that there are no shadows in here. What we’re doing is less of a “classic” shadow than it is a simple way to duplicate the text.

Diagram of the start and end of the hover effect.

We have two text layers that we move on hover. If we hide the overflow, then the duplicated text is out of view and the movement makes it appear as though the actual text is being replaced by other text. This is the main trick that that makes all of the examples in this article work.

Let’s optimize our code. I am using the value 1.2em a lot to define the height and the offset of the shadows, making it an ideal candidate for a CSS custom property (which we’re calling --h):

.hover-1 {
  --h: 1.2em;

  line-height: var(--h);
  color: #0000;
  text-shadow: 
    0 0 #000, 
    0 var(--h) #1095c1;
  overflow: hidden;
  transition: .3s;
}
.hover-1:hover {
  text-shadow: 
    0 calc(-1 * var(--h)) #000, 
    0 0 #1095c1;
}

We can still go further and apply more calc()-ulations to streamline things to where we only use the text-shadow once. (We did the same in the previous article.)

.hover-1 {
  --h: 1.2em;   

  line-height: var(--h);
  color: #0000;
  text-shadow: 
    0 calc(-1*var(--_t, 0em)) #000, 
    0 calc(var(--h) - var(--_t, 0em)) #1095c1;
  overflow: hidden;
  transition: .3s;
}
.hover-1:hover {
  --_t: var(--h);
}

In case you are wondering why I am adding an underscore to the --_t variable, it’s just a naming convention I am using to distinguish between the variables we use to control the effect that the user can update (like --h) and the internal variables that are only used for optimization purposes that we don’t need to change (like --_t ). In other words, the underscore is part of the variable name and has no special meaning.

We can also update the code to get the opposite effect where the duplicated text slides in from the top instead:

All we did is a small update to the text-shadow property — we didn’t touch anything else!

Hover effect #2

For this one, we will animate two properties: text-shadow and background. Concerning the text-shadow, we still have two layers like the previous example, but this time we will move only one of them while making the color of the other one transparent during the swap.

.hover-2 {
  /* the height */
  --h: 1.2em;

  line-height: var(--h);
  color: #0000;
  text-shadow: 
    0 var(--_t, var(--h)) #fff,
    0 0 var(--_c, #000);
  transition: 0.3s;
}
.hover-2:hover {
  --_t: 0;
  --_c: #0000;
}

On hover, we move the white text layer to the top while changing the color of the other one to transparent. To this, we add a background-size animation applied to a gradient:

And finally, we add overflow: hidden to keep the animation only visible inside the element’s boundaries:

.hover-2 {
  /* the height */
  --h: 1.2em;

  line-height: var(--h);
  color: #0000;
  text-shadow: 
    0 var(--_t,var(--h)) #fff,
    0 0 var(--_c, #000);
  background: 
    linear-gradient(#1095c1 0 0) 
    bottom/100% var(--_d, 0) no-repeat;
  overflow: hidden;
  transition: 0.3s;
}
.hover-2:hover {
  --_d: 100%;
  --_t: 0;
  --_c: #0000;
}

What we’ve done here is combine the CSS text-shadow and background properties to create a cool hover effect. Plus, we were able to use CSS variables to optimize the code.

If the background syntax looks strange to you, I highly recommend reading my previous article. The next hover effect also relies on an animation I detailed in that article. Unless you are comfortable with CSS background trickery, I’d suggest reading that article before continuing this one for more context.

In the previous article, you show us how to use only one variable to create the hover effect — is it possible to do that here?

Yes, absolutely! We can indeed use that same DRY switching technique so that we’re only working with a single CSS custom property that merely switches values on hover:

.hover-2 {
  /* the height */
  --h: 1.2em;

  line-height: var(--h);
  color: #0000;
  text-shadow: 
    0 var(--_i, var(--h)) #fff,
    0 0 rgb(0 0 0 / calc(var(--_i, 1) * 100%) );
  background: 
    linear-gradient(#1095c1 0 0) 
    bottom/100% calc(100% - var(--_i, 1) * 100%) no-repeat;
  overflow: hidden;
  transition: 0.3s;
}
.hover-2:hover {
  --_i: 0;
}

Hover effect #3

This hover effect is nothing but a combination of two effects we’ve already made: the second hover effect of the previous article and the first hover effect in this article.

.hover-3 {
  /* the color  */
  --c: #1095c1;
  /* the height */
  --h: 1.2em;

  /* The first hover effect in this article */
  line-height: var(--h);  
  color: #0000;
  overflow: hidden;
  text-shadow: 
    0 calc(-1 * var(--_t, 0em)) var(--c), 
    0 calc(var(--h) - var(--_t, 0em)) #fff;
  /* The second hover effect from the previous article */
  background: 
    linear-gradient(var(--c) 0 0) no-repeat 
    calc(200% - var(--_p, 0%)) 100% / 200% var(--_p, .08em);
  transition: .3s var(--_s, 0s), background-position .3s calc(.3s - var(--_s, 0s));
}
.hover-3:hover {
  --_t: var(--h);
  --_p: 100%;
  --_s: .3s
}

All I did was copy and paste the effects from those other examples and make minor adjustments to the variable names. They make for a neat hover effect when they’re combined! At first glance, such an effect may look complex and difficult but, in the end, it’s merely two relatively easy effects made into one.

Optimizing the code with the DRY switching variable technique should also be an easy task if we consider the previous optimizations we’ve already done:

.hover-3 {
  /* the color  */
  --c: #1095c1;
  /* the height */
  --h: 1.2em;

  line-height: var(--h);  
  color: #0000;
  overflow: hidden;
  text-shadow: 
    0 calc(-1 * var(--h) * var(--_i, 0)) var(--c), 
    0 calc(var(--h) * (1 - var(--_i, 0))) #fff;
  background: 
    linear-gradient(var(--c) 0 0) no-repeat
    calc(200% - var(--_i, 0) * 100%) 100% / 200% calc(100% * var(--_i, 0) + .08em);
  transition: .3s calc(var(--_i, 0) * .3s), background-position .3s calc(.3s - calc(var(--_i, 0) * .3s));
}
.hover-3:hover {
  --_i: 1;
}

Hover effect #4

This hover effect is an improvement of the second one. First, let’s introduce a clip-path animation to reveal one of the text layers before it moves:

Here’s another illustration to better understand what is happening:

Diagram of the start and end of the text hover.

Initially, we use inset(0 0 0 0) which is similar to overflow: hidden in that all we see is the actual text. On hover, we update the the third value (which represent the bottom offset) using a negative value equal to the height to reveal the text layer placed at the bottom.

From there, we can add this to the second hover effect we made in this article, and this is what we get:

We are getting closer! Note that we need to first run the clip-path animation and then everything else. For this reason, we can add a delay to all of the properties on hover, except clip-path:

transition: 0.4s 0.4s, clip-path 0.4s;

And on mouse out, we do the opposite:

transition: 0.4s, clip-path 0.4s 0.4s;

The final touch is to add a box-shadow to create the sliding effect of the blue rectangle. Unfortunately, background is unable to produce the effect since backgrounds are clipped to the content area by default. Meanwhile, box-shadow can go outside the content area.

.hover-4 {
  /* the color  */
  --c: #1095c1;
  /* the height */
  --h: 1.2em;
  
  line-height: var(--h);
  color: #0000;
  text-shadow: 
    0 var(--_t, var(--h)) #fff,
    0 0 var(--_c, #000);
  box-shadow: 0 var(--_t, var(--h)) var(--c);
  clip-path: inset(0 0 0 0);
  background: linear-gradient(var(--c) 0 0) 0 var(--_t, var(--h)) no-repeat;
  transition: 0.4s, clip-path 0.4s 0.4s;
}
.hover-4:hover {
  --_t: 0;
  --_c: #0000;
  clip-path: inset(0 0 calc(-1 * var(--h)) 0);
  transition: 0.4s 0.4s, clip-path 0.4s;
}

If you look closely at the box-shadow, you will see it has the same values as the white text layer inside text-shadow. This is logical since both need to move the same way. Both will slide to the top. Then the box-shadow goes behind the element while text-shadow winds up on the top.

Here is a demo with some modified values to visualize how the layers move:

Wait, The background syntax is a bit different from the one used in the second hover effect!

Good catch! Yes, we are using a different technique with background that produces the same effect. Instead of animating the size from 0% to 100%, we are animating the position.

If we don’t specify a size on our gradient, then it take up the full width and height by default. Since we know the height of our element (--h) we can create a sliding effect by updating the position from 0 var(--h) to 0 0.

.hover-4 {
  /* ... */
  background: linear-gradient(var(--c) 0 0) 0 var(--_t, var(--h)) no-repeat;
}
.hover-4:hover {
  --_t: 0;
}

We could have used the background-size animation to get the same effect, but we just added another trick to our list!

In the demos, you also used inset(0 0 1px 0)… why?

I sometimes add or remove a few pixels or percentages here and there to refine anything that looks off. In this case, a bad line was appearing at the bottom and adding 1px removed it.

What about the DRY switch variable optimization?

I am leaving this task for you! After those four hover effects and the previous article, you should be able to update the code so it only uses one variable. I’d love to see you attempt it in the comments!

Your turn!

Let me share one last hover effect which is another version of the previous one. Can you find out how it’s done without looking at the code? It’s a good exercise, so don’t cheat!

Wrapping up

We looked at a bunch of examples that show how one element and few lines of CSS are enough to create some pretty complex-looking hover effects on text elements — no pseudo elements needed! We were even able to combine techniques to achieve even more complex animations with a small amount of effort.

If you’re interested in going deeper than the four text-shadow hover effects in this article, check my collection of 500 hover effects where I am exploring all kinds of different techniques.


Cool Hover Effects That Use CSS Text Shadow originally published on CSS-Tricks. You should get the newsletter.