00:00:00

Recreating Dia's H1

The landing page for Dia is really well done. The H1 text has a colorful animation which is not the best part, but it can be outlined easily in a blog post.

The first word changes continuously through a set of words like Chat, Write, Plan and Learn.

The current words fades out and the next word fades in from left to right. We first see blue, then yellow, red, pink and finally black which stays visible until the next word change.

Intuition

The way to think about this is sliding a ribbon of colors behind a stencil cutout of the text. By moving the ribbon left or right, we can change which colors are visible through the text.

Create

The character outlines are implemented using -webkit-text-stroke. This is supported in all major browsers1 but it is an unofficial property.

With the above demo we can see there are two major parts to the component.

  • The ribbon of colors that slides left to right.
  • A method to position and move the ribbon behind the text.

Creating the Ribbon

We'll use five colors for our ribbon.

The ribbon can be created using the linear-gradient CSS function. The linear-gradient() CSS function creates an image consisting of a progressive transition between two or more colors along a straight line2. The function can be assigned to the background-image property.

For our purposes, its very important we think of the ribbon as an image. This means we can use properties like background-size and background-position to manipulate it.

html
<div class="ribbon"></div>
<div class="ribbon"></div>
css
.ribbon {
    width: 200px;
    height: 100px;
    background-image: linear-gradient(to right, #1FBFBA, #2D7FF9, #7A4FF1, #D741A7, #F48C2B);
}
.ribbon {
    width: 200px;
    height: 100px;
    background-image: linear-gradient(to right, #1FBFBA, #2D7FF9, #7A4FF1, #D741A7, #F48C2B);
}

The to right value means the gradient will go from left to right (90 degrees). The colors will transition evenly across the width of the element.

Color stops

Instead of having the colors transition evenly, we can define color stops. Color stops define where each color starts and ends3.

css
.ribbon {
    background-image: linear-gradient(to right, 
        #1FBFBA 0%, 
        #2D7FF9 20%, 
        #7A4FF1 40%, 
        #D741A7 60%,
        #F48C2B 80%);
}
.ribbon {
    background-image: linear-gradient(to right, 
        #1FBFBA 0%, 
        #2D7FF9 20%, 
        #7A4FF1 40%, 
        #D741A7 60%,
        #F48C2B 80%);
}

These color stops are defined as percentages of the total width of the background image. The above values are equivalent to not defining any color stops at all since the colors are evenly spaced.

This will be useful when we want to set some part of the ribbon to transparent to allow for an invisible starting point for the animation.

Background Size & Position

Drone shot of Trees
Credits: Gabriel. unsplash@spenas88

What if we only wanted to show one of the cubes without editing the image? We can do this by manipulating the background-size and background-position properties.

The background-position CSS property sets the initial position for each background image. The position is relative to the position layer set by background-origin4.

background-size: 100%
background-position-x: 50.0%
background-position-y: 50.0%

Its hard to give a better explanation of these properties than ones already given at MDN or at CSS Tricks.

The key takeaway is that by changing the background-position we can slide the ribbon of colors left or right behind the text.

background-size allows us to zoom into parts of the ribbon not because we need to, but because we want to hide parts of the ribbon from view.

Instead of three cubes we will have the intial transparent part, a moving colorful part and a final black part.

Transparent. Why?

We need a transparent part so that when the component first loads, we don't see any colors. The colorful part should only be visible when it slides in from the left.

Without the transparent part, the colorful part will be visible immediately on load. We would see the colors change but we would not get the effect of the colors sliding in from the left.

Putting it all together

So the ribbon will have three parts:

  • A transparent part at the start
  • The colorful part in the middle
  • A final color part at the end

All three will be the same width.

Using Color stops we can define the ribbon as below:

css
.ribbon{
    background-image: linear-gradient(
        to right,
        #000000 0,
        #000000 33.33%,
        #1FBFBA 40%,
        #2D7FF9 45%,
        #7A4FF1 50%,
        #D741A7 55%,
        #F48C2B 60%,
        transparent 66.67%,
        transparent 100%);
    font-size: 2rem;
    font-weight: bold;
    font-family: 'Host Grotesk', sans-serif;
    color: maroon;
    text-align: center;
}
.ribbon{
    background-image: linear-gradient(
        to right,
        #000000 0,
        #000000 33.33%,
        #1FBFBA 40%,
        #2D7FF9 45%,
        #7A4FF1 50%,
        #D741A7 55%,
        #F48C2B 60%,
        transparent 66.67%,
        transparent 100%);
    font-size: 2rem;
    font-weight: bold;
    font-family: 'Host Grotesk', sans-serif;
    color: maroon;
    text-align: center;
}

Linear Gradient

Chat with your tabs

The linear-gradient has a black part from 0% to 33.33%, then the colorful part from 33.33% to 66.67% and finally the transparent part from 66.67% to 100%.

The maroon text is only for visibility.

Background Size & Position

Now we only want one third of the ribbon to be visible at any time. So we zoom in by setting the background-size to 300% (3 times the width of the element).

css
.ribbon{
    background-size: 300%;
}
.ribbon{
    background-size: 300%;
}
Chat with your tabs

Which part of the ribbon is visible is determined by the background-position. It defaults to 0%. So as we see above the first third of the ribbon is visible, which is the black part.

Intially we want to see the transparent part. Since the vertical position is not important, we can update background-position-x.

Setting background-position-x to 100% we see the transparent part.

css
.ribbon{
    background-position-x: 100%;
}
.ribbon{
    background-position-x: 100%;
}

This value of background-position-x corresponds directly with the linear-gradient color stops. If we set it to 50% we would see the colorful part.

Neither background-position nor linear-gradient color stops are affected by background-size. For our purposes background-size is just a zoom level.

Chat with your tabs

Animation

Now comes the animation part. We can animate the background-position-x from 100% to 0% to slide the colorful part into view.

css
.ribbon{
    animation-name: ribbon-slide;
    animation-delay: 100ms;
    animation-duration: 900ms;
    animation-timing-function: ease-in-out;
    animation-fill-mode: forwards;
}
@keyframes ribbon-slide {
    0% {background-position-x: 100%;}
    100% {background-position-x: 0%;}
}
.ribbon{
    animation-name: ribbon-slide;
    animation-delay: 100ms;
    animation-duration: 900ms;
    animation-timing-function: ease-in-out;
    animation-fill-mode: forwards;
}
@keyframes ribbon-slide {
    0% {background-position-x: 100%;}
    100% {background-position-x: 0%;}
}

Now there is a lot of stuff happening here. The animation will start after a 100ms delay, it will last for 300ms and it will ease in and out. Finally the forwards fill mode means that after the animation ends, the final state background-position-x: 0% will be retained.

The ribbon-slide keyframes defines the animation itself. At the start (0%) the background-position-x is 100% (transparent part visible) and at the end (100%) it is 0% (black part visible).

Chat with your tabs
animation-duration: 2000ms

The Final Trick

Now the final thing which makes this work is to make the text act as a stencil. This is done using the background-clip: text property.

We also need to make the text fill color transparent so that the background is visible through the text.

css
.ribbon{
    background-clip: text;
    -webkit-background-clip: text; /* for Safari */
    color: transparent;
}
.ribbon{
    background-clip: text;
    -webkit-background-clip: text; /* for Safari */
    color: transparent;
}
Chat with your tabs
animation-duration: 2000ms

Cycling through words

Cycling through words can be done using JavaScript or a framework of your choice. The idea is to change the text content every few seconds and re-trigger the animation.

The main challenge is preventing layout shift when the words change. As seen below, changing the words causes the whole heading to shift.

Chat with your tabs

Preventing Layout Shift

This can be solved by setting a fixed width for the heading based on the longest word. This way when the words change, the width remains constant and there is no layout shift.

Also we use text-align: right so that shorter words align to the right edge. This keeps the visual position of the words consistent.

We define the width in ch units which are based on the width of the 0 character in the current font. This makes it easier to estimate the width needed for the longest word.

html
<h1 class="heading"> 
    <span class="word">Chat</span> with your tabs
</h1>
<h1 class="heading"> 
    <span class="word">Chat</span> with your tabs
</h1>
css
.heading{
    font-size: 2rem;
    font-weight: bold;
    font-family: "Host Grotesk", sans-serif;
    gap: 0.5ch;
    display: flex;
    align-items: center;
}
.word {
    width: 5ch; /* Width for longest word */
    text-align: right;
    white-space: nowrap; /* Prevents wrapping */
    overflow: hidden; /* Hides overflow */
}
.heading{
    font-size: 2rem;
    font-weight: bold;
    font-family: "Host Grotesk", sans-serif;
    gap: 0.5ch;
    display: flex;
    align-items: center;
}
.word {
    width: 5ch; /* Width for longest word */
    text-align: right;
    white-space: nowrap; /* Prevents wrapping */
    overflow: hidden; /* Hides overflow */
}

display: flex is used to align the word and the rest of the heading text horizontally with a gap of 0.5ch between them.

The white-space: nowrap prevents the words from wrapping to the next line and overflow: hidden ensures that there is no layout shift the words is instead clipped if it exceeds the width.

Chatwith your tabs

For all other words except the longest one, the heading is not perfectly centered. But it does still look visually centered. So its a good tradeoff to prevent layout shift.

Re-triggering the Animation

Remember to reset the background-position-x to 100% before re-triggering the animation so that the animation always starts from the transparent part.

Beyond that, the implementation details will depend on the framework you are using.

React Implementation

The animating word component can be implemented in React as below:

Chat
with your tabs
DiaH1.jsx
"use client";
import { useState, useEffect } from "react";
import styles from "./DiaH1.module.css";

const words = ["Chat", "Write", "Plan", "Learn"];

export default function DiaH1() {
    const [currentWordIndex, setCurrentWordIndex] = useState(0);
    const [opacity, setOpacity] = useState(1);

    useEffect(() => {
        const interval = setInterval(() => {
            setOpacity(0); // Fade out
            setTimeout(() => {
                setCurrentWordIndex((prev) => (prev + 1) % words.length);
                setOpacity(1); // Fade in
            }, 300); 
        }, 3000);
        return () => clearInterval(interval);
    }, []);

    return (
        <div className={styles.heading}>
            <div className={styles.wordWrapper} 
                style={{ opacity, transition: 'opacity 0.3s' }}
            >
                <span key={currentWordIndex} className={styles.word}>
                    {words[currentWordIndex]}
                </span>
            </div>
            with your tabs
        </div>
    );
}
"use client";
import { useState, useEffect } from "react";
import styles from "./DiaH1.module.css";

const words = ["Chat", "Write", "Plan", "Learn"];

export default function DiaH1() {
    const [currentWordIndex, setCurrentWordIndex] = useState(0);
    const [opacity, setOpacity] = useState(1);

    useEffect(() => {
        const interval = setInterval(() => {
            setOpacity(0); // Fade out
            setTimeout(() => {
                setCurrentWordIndex((prev) => (prev + 1) % words.length);
                setOpacity(1); // Fade in
            }, 300); 
        }, 3000);
        return () => clearInterval(interval);
    }, []);

    return (
        <div className={styles.heading}>
            <div className={styles.wordWrapper} 
                style={{ opacity, transition: 'opacity 0.3s' }}
            >
                <span key={currentWordIndex} className={styles.word}>
                    {words[currentWordIndex]}
                </span>
            </div>
            with your tabs
        </div>
    );
}

There are a couple of things that were added here to improve the experience.

A fade-out and fade-in effect is added when changing words. This is done by changing the opacity of the word wrapper with a transition. This makes the word change less abrupt. The duration is 300ms.

A key is added to the word span. This forces React to treat each word as a new element, ensuring the animation restarts correctly when the word changes. This way we don't have to manually reset the background-position-x. Other frameworks will have their own way of doing this.

DiaH1.module.css
.heading {
    font-size: 2rem;
    font-weight: bold;
    font-family: 'Host Grotesk', sans-serif;
    text-align: center;
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 0.5ch;
}
.wordWrapper {
    width: 5ch;
    text-align: right;
}
.word {
    text-align: right;
    white-space: nowrap;
    overflow: hidden;
    background-image: linear-gradient(
        to right,
        #000000 0,
        #000000 33.33%,
        #1FBFBA 40%,
        #2D7FF9 45%,
        #7A4FF1 50%,
        #D741A7 55%,
        #F48C2B 60%,
        transparent 66.67%,
        transparent 100%);
    background-size: 300%;
    background-position: 100%;
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
    color: transparent;
    animation-name: ribbon-slide;
    animation-delay: 100ms;
    animation-duration: 900ms;
    animation-timing-function: ease-in-out;
    animation-fill-mode: forwards;
}
@keyframes ribbon-slide {
    0% { background-position-x: 100%; }
    100% { background-position-x: 0%; }
}
.heading {
    font-size: 2rem;
    font-weight: bold;
    font-family: 'Host Grotesk', sans-serif;
    text-align: center;
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 0.5ch;
}
.wordWrapper {
    width: 5ch;
    text-align: right;
}
.word {
    text-align: right;
    white-space: nowrap;
    overflow: hidden;
    background-image: linear-gradient(
        to right,
        #000000 0,
        #000000 33.33%,
        #1FBFBA 40%,
        #2D7FF9 45%,
        #7A4FF1 50%,
        #D741A7 55%,
        #F48C2B 60%,
        transparent 66.67%,
        transparent 100%);
    background-size: 300%;
    background-position: 100%;
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
    color: transparent;
    animation-name: ribbon-slide;
    animation-delay: 100ms;
    animation-duration: 900ms;
    animation-timing-function: ease-in-out;
    animation-fill-mode: forwards;
}
@keyframes ribbon-slide {
    0% { background-position-x: 100%; }
    100% { background-position-x: 0%; }
}

The width was moved from the word to a wrapper element to prevent issues with the animation and text clipping.

The gap 0.5ch is arbitrarily chosen but intended to match the spacing between words in the rest of the heading.

Footnotes

  1. "text-stroke" | Can I use ↩

  2. MDN Web Docs - linear-gradient() ↩

  3. MDN Web Docs - linear-gradient() ↩

  4. MDN Web Docs - background-image ↩