The CSS translate() function shifts an element from its default position on a two-dimensional plane. This way, we can reposition an element horizontally, vertically, or both.
…which is just a fancy way of saying we can move (translate) and element by one or two lengths or percentages. We’ll get into some examples in a bit. But first:
Arguments
/* Single argument */
translate(100px) /* moves 100px to the right */
translate(-100%) /* moves 100% of the element's width to the left */
/* Double argument */
translate(50px, 100px) /* moves 50px down, then 100px to the right */
translate(50%, 100%) /* moves 50% of the element's width downwards, then 100% its height to the right */
The translate() function takes two <length-percentage> arguments (tx, ty, as in “translate horizontally” and “translate vertically”). These tell the browser how much to move the element and in which direction direction (whether it’s positive or negative).
tx: Specifies the displacement in the horizontal axis. If it’s positive, the element goes right. If it’s negative, the element shifts to the left.
ty (optional): Specifies the displacement in the vertical axis. If it’s positive, the element goes downward, and if it’s negative, the element moves upward.
If only one argument is passed, it’s assumed to be tx. Alternatively, when both arguments are passed, the second argument will be ty. Together, they shift the element diagonally.
You’ll also notice that we can use either <length> or <percentage> values. A <length> value is absolute, while a <percentage> value is relative to the element’s width (for tx) or height (for ty).
Basic usage
While we have many ways to center an element in CSS, for most of its history, our best shot to center an absolute element was using the translate() function.
The process goes as follows: given an absolute element, we usually shift it to the center using top: 50% and left: 50%. However, these alone only fix the top-left corner of the element in the center, not the element itself. To fix this, we use transform: translate(-50%, -50%) to shift the element back by half of its own width and height.
The translateX() function moves elements horizontally, while translateY() handles the vertical axis. If we instead want diagonal movement, we could combine both or use the shorter translate() function.
A common use case would be to translate an element into the page from any corner. For example, if we had a Toast component and wanted it to slide in from the bottom-right, we could move the element through the bottom and right properties, then offset them off the page with translate().
The translate() function, like other transform functions, does not affect the document flow. Instead, it visually displaces the translated element to a new position without pushing the elements in its surroundings or the ones at the new position. Also, the space the element originally occupied remains reserved in the layout as if it hadn’t moved at all.
Notice how the “translated” element does not cause the Box 1 or Box 2 elements next to it to shift when it is moved.
Unlike margin, which can trigger reflows or shift neighboring elements, translate() only changes where the element is visually rendered.
Issues with pointer pseudo-classes
Using translate() directly on a pointer pseudo-classes like :hover can sometimes make for bad interactions. In a situation where the element is translated far enough from the cursor, the :hover state ends, causing the element to snap back immediately to its original position. A position where the cursor still lingers, so it translates again, resulting in a continuous flickering loop.
A simple solution to this is to place the element to be translated in a parent container, and apply the pseudo-class (:hover) to the parent, while the main element takes the translate function.
/* Problem case */
.bad:hover {
transform: translateX(160px);
}
/* Solution */
.parent:hover .good {
transform: translateX(160px);
}
Demo
Specification
The CSS translate() function is defined in the CSS Transforms Module Level 1, which is currently in Editor’s Drafts.
Browser support
The translate() function has baseline support on all modern browsers.
Sometimes designers have silly ideas that eventually grow on you. That happened to me with this concept where I had to build columns of items moving in opposite directions when a user scrolls the page.
Note: This demo respects reduced motion settings, so you’ll need to enable motion to see the effect. And we’re looking at Chrome and Safari support as I’m writing this.
It’s really not as hard as you might think, thanks to modern CSS features, specifically scroll-driven animations. Mot only that, but it’s fun to make, too! Let me show you how I approached it — and maybe you will want to share how you would do it differently.
The HTML
The HTML consists of a parent element (.opposing-columns), its children (.opposing-column), and its children’s children (.opposing-item):
This is all we need in the markup. CSS will do the rest!
Styling the parent container
First off, we’re going to set things up so that this effect only applies to larger screens — there’s no real sense supporting something like this on smaller screens because we need the additional space for the effect.
/* Just on larger screens */
@media screen and (width >= 50rem) {
.opposing-columns {
display: flex;
gap: 2rem;
max-inline-size: min(90dvi, 50rem);
margin-inline: auto;
}
}
Setting up a “masking” effect
We need to do a few more things with the parent container to get the illusion that items in each .opposing-column are disappearing as they scroll past it. The items in the outer columns move upward on scroll, and items in the center column move downward. As they cross the parent’s boundaries, we want them to sorta fade out.
So, we’re going to do a few things. First, we’ll set a background color variable on the document as a whole:
@media screen and (width >= 50rem) {
:root {
--opposing-bg: lightcyan;
background-color: var(--opposing-bg);
}
.opposing-columns {
/* same styles as before */
}
}
Second, we’ll apply that same background color on the parent’s :before and :after pseudo-elements:
Notice that we’ve established a stacking context on the pseudos and set them one layer above the parent and its descendants. This is key for masking the items in each column as they scroll in and out of the container. Thew items are technically sliding under the pseudo masks.
Speaking of which, let’s create another variable called --opposing-mask that adds vertical space between the parent element and the three columns:
Let’s do the same thing to the parent’s pseudos, only applying --opposing-mask to their block-size by a multiple of three. This way, there’s additional vertical space between them and the parent.
You might see where this is going. We have a nice amount of space between the parent container and its pseudos. We want the column items to appear as if they are fading out as they scroll out of the parent container. We don’t have to mess with their opacity or anything like that. Instead, we can add background gradients on the pseudos.
The :before pseudo is at the top of the container, so we’ll give it a gradient that goes from a solid color that matches the document’s underlying background color to transparent, top-to-bottom. And since the :after pseudo sits at the bottom of the parent container, we’ll reverse the gradient so it goes transparent to the document’s background color, bottom-to-top.
@media screen and (width >= 50rem) {
:root {
/* same styles as before */
}
.opposing-columns {
/* same styles as before */
&:before,
&:after {
/* same styles as before */
}
&:before {
background-image: linear-gradient(
to bottom,
var(--opposing-bg) var(--opposing-mask),
transparent
);
inset-block-start: calc(var(--opposing-mask) * -1);
}
&:after {
background-image: linear-gradient(
to top,
var(--opposing-bg) var(--opposing-mask),
transparent
);
inset-block-end: calc(var(--opposing-mask) * -1);
}
}
}
}
The column layouts
Before we get to the magic, we ought to lay out the items in each column. Each column is a flex item inside the parent, which is a flex container. We’ll let them shrink (flex-shrink: 1) and grow (flex-grow: 1), capping the size at a certain point (flex-basis: 10rem).
We can define all that with the flex shorthand property:
@media screen and (width >= 50rem) {
/* same styles as before */
.opposing-column {
flex: 1 1 10rem;
}
}
Now I want those columns to be grid containers so I can use the gap property to insert space between items:
@media screen and (width >= 50rem) {
/* same styles as before */
.opposing-column {
flex: 1 1 10rem;
display: grid;
gap: 2rem;
}
}
We totally could have used Flexbox here as well to get access to gap, but the default layout is set to row and we’d have to override that to column. Grid is a little more concise in this situation.
The animation!
This is what you came for, right? We’ve set everything up so that column items can flow in and out of the parent container on scroll. Now we need to add that scrolling behavior.
This is where the animation-timeline property comes real handy. Normally, a CSS animation just runs on its own. It starts when the page loads (or after a specific delay you set) and ends after however long you set the duration. With animation-timeline, we tell the animation to run based on its scroll position… hence the term “scroll-driven” animation.
We have two supported functions here, scroll() and view(). They’re related but super different in that scroll() runs the animation based on an element’s scroll position. The view() function is similar, but tracks the element’s progress as it enters and exits the scrollport (i.e., the scrollable area of the container it is in).
We’re going with the view() function because we’ve set this up where there is a clear scrollable area inside the parent container. We need to run the animation based on where it enters and exits that area rather than the scroll position of the column items.
This is real interesting because we can tell view() where exactly we want the animation to start once it enters the scrollable area and where to stop once it exits that same area. Like this:
/* Official syntax */
animation-timeline: view([ <axis> || <'view-timeline-inset'>]?);
Let’s start by defining the axes:
@media screen and (width >= 50rem) {
/* same styles as before */
.opposing-column {
/* ... */
animation-timeline: view();
animation-range: entry cover;
}
}
This is just partially what we want, but what we’re saying is we want the animation to (1) start the very moment is enters the scrollport (entry), and (2) end when it completely leaves the area (cover). We need to be explicitly about the insets because that’s what establishes the animation’s range relative to where it enters and exits. We want the full range, so the entry begins at 0% and the exit is when an item is covered at 100%.
@media screen and (width >= 50rem) {
/* same styles as before */
.opposing-column {
/* ... */
animation-timeline: view();
animation-range: entry 0% cover 100%;
}
}
Lastly, we’ll set the animation to run linearly — no need for the items to slow up or down as they scroll.
@media screen and (width >= 50rem) {
/* same styles as before */
.opposing-column {
/* ... */
animation-timing-function: linear;
animation-timeline: view();
animation-range: entry 0% cover 100%;
}
}
OK, great. But what we haven’t done is create an animation. We’ve set up what we want it to do when it runs, but we need to define the actual movement.
I want to set up three separate CSS animations:
One that translates (moves) the items upward in the first column.
One that’s the reverse of the first animation for the items in the other column.
We could technically set the first animation on both of the outer columns, but I want a third one that is a little bit offset from the first so those columns appear staggered.
@keyframes scroll1 {
from { transform: translateY(var(--opposing-mask)); }
to { transform: translateY(calc(var(--opposing-mask) * -1)); }
}
@keyframes scroll2 {
from { transform: translateY(calc(var(--opposing-mask) * -1)); }
to { transform: translateY(var(--opposing-mask)); }
}
@keyframes scroll3 {
from { transform: translateY(calc(var(--opposing-mask) * .66)); }
to { transform: translateY(calc(var(--opposing-mask) * -.33)); }
}
We can create variables for these, of course, should we ever need to update them:
So yeah, scroll-driven animations are really, really cool. We’re still waiting for Firefox support as I’m writing this, but you can certainly wrap this in @supports to provide a default experience that uses thew scroll annotations and then set a fallback experience for non-supporting browsers, like running on a normal animation timeline:
This is just toe-dipping into what scroll-driven animations can do, of course. What sort of things have you made or experimented with? Or would you approach this one differently? Let me know!