00:00:00
|

Recreating Osmo.supply's button component

Osmo.supply has one of the best landing pages I've seen for sometime. Although there is much more to it than just a button, I wanted to recreate the button because interestingly it can be recreated without Framer Motion.

There are two main animations working together here:

The button scales down slightly on hover. This is a bit unorthodox but it catches the eye.

The text inside slides up and reappears from below with each letter staggered. This is an extension of the common slide up on hover effect.

To start off here is some basic markup and styles.

html
<div class="button">
    <div class="clipContainer">
        <div class="text">Hover Me</div>
        <div class="text">Hover Me</div>
    </div>
</div>
<div class="button">
    <div class="clipContainer">
        <div class="text">Hover Me</div>
        <div class="text">Hover Me</div>
    </div>
</div>
css
.button{
    font-family: 'Host Grotesk';
    font-size: 1.33rem;
    font-weight: 420;
    background-color: white;
    color: black;
    padding: 0.5rem 1rem;
    border-radius: 0.25rem;
    cursor: pointer;
    display: inline-flex;
    transition: transform 0.25s ease;
    /* box-shadow only for visibility on white backgrounds */
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
    --clipHeight: 1.4em;
}
.clipContainer{
    position: relative;
    display: inline-flex;
    height: var(--clipHeight);
    overflow: hidden;
    flex-direction: column;
}
.text{
    position: relative;
    display: inline-flex;
    align-items: center;
    height: var(--clipHeight);
    transition: transform 0.25s ease;
    transform: translateY(0);
}
.button{
    font-family: 'Host Grotesk';
    font-size: 1.33rem;
    font-weight: 420;
    background-color: white;
    color: black;
    padding: 0.5rem 1rem;
    border-radius: 0.25rem;
    cursor: pointer;
    display: inline-flex;
    transition: transform 0.25s ease;
    /* box-shadow only for visibility on white backgrounds */
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
    --clipHeight: 1.4em;
}
.clipContainer{
    position: relative;
    display: inline-flex;
    height: var(--clipHeight);
    overflow: hidden;
    flex-direction: column;
}
.text{
    position: relative;
    display: inline-flex;
    align-items: center;
    height: var(--clipHeight);
    transition: transform 0.25s ease;
    transform: translateY(0);
}

It is easier to use div here instead of button because we don't have to worry about nesting the div inside a button which is invalid HTML.

--clipHeight is a CSS variable that helps us manage the height of the text container. This allows the user to see the text slide up and appear from below.

The duplicate .text elements inside the .clipContainer help achieve the slide up effect.

Slide up on Hover

To achieve the slide up effect, we need to move the visible text up by the height of the container and move the hidden text into view.

css
.button:hover .text{
    transform: translateY(calc(-1 * var(--clipHeight)));
}
.button:hover .text{
    transform: translateY(calc(-1 * var(--clipHeight)));
}

It needs to be negative because we are moving the text up.

Now we have something that looks like this:

Hover me
Hover me

Scale Down on Hover

To achieve the scale down effect, we simply apply a scale transform to the button on hover.

css
.button:hover{
    scale: 0.95;
}
.button:hover{
    scale: 0.95;
}

Now we see the button scale down slightly on hover:

Hover me
Hover me

Staggered per-letter Slide up

Things get a bit more hectic when we want to achieve the staggered per-letter slide up effect.

To do this, we need to wrap each letter in a separate container. This allows us to animate each letter individually.

html
<div class="button">
    <div class="clipContainer">
        <div class="text" style="transition-delay: 0ms;">H</div>
        <div class="text" style="transition-delay: 0ms;">H</div>
    </div>
    <div class="clipContainer">
        <div class="text" style="transition-delay: 7ms;">o</div>
        <div class="text" style="transition-delay: 7ms;">o</div>
    </div>
    <div class="clipContainer">
        <div class="text" style="transition-delay: 14ms;">v</div>
        <div class="text" style="transition-delay: 14ms;">v</div>
    </div>
    <div class="clipContainer">
        <div class="text" style="transition-delay: 21ms;">e</div>
        <div class="text" style="transition-delay: 21ms;">e</div>
    </div>
    <div class="clipContainer">
        <div class="text" style="transition-delay: 28ms;">r</div>
        <div class="text" style="transition-delay: 28ms;">r</div>
    </div>
    <div class="clipContainer">
        &nbsp;
    </div>
    <div class="clipContainer">
        <div class="text" style="transition-delay: 35ms;">m</div>
        <div class="text" style="transition-delay: 35ms;">m</div>
    </div>
    <div class="clipContainer">
        <div class="text" style="transition-delay: 42ms;">e</div>
        <div class="text" style="transition-delay: 42ms;">e</div>
    </div>
</div>
<div class="button">
    <div class="clipContainer">
        <div class="text" style="transition-delay: 0ms;">H</div>
        <div class="text" style="transition-delay: 0ms;">H</div>
    </div>
    <div class="clipContainer">
        <div class="text" style="transition-delay: 7ms;">o</div>
        <div class="text" style="transition-delay: 7ms;">o</div>
    </div>
    <div class="clipContainer">
        <div class="text" style="transition-delay: 14ms;">v</div>
        <div class="text" style="transition-delay: 14ms;">v</div>
    </div>
    <div class="clipContainer">
        <div class="text" style="transition-delay: 21ms;">e</div>
        <div class="text" style="transition-delay: 21ms;">e</div>
    </div>
    <div class="clipContainer">
        <div class="text" style="transition-delay: 28ms;">r</div>
        <div class="text" style="transition-delay: 28ms;">r</div>
    </div>
    <div class="clipContainer">
        &nbsp;
    </div>
    <div class="clipContainer">
        <div class="text" style="transition-delay: 35ms;">m</div>
        <div class="text" style="transition-delay: 35ms;">m</div>
    </div>
    <div class="clipContainer">
        <div class="text" style="transition-delay: 42ms;">e</div>
        <div class="text" style="transition-delay: 42ms;">e</div>
    </div>
</div>

Because of the inline styles, the transition delays are staggered by 7ms for each letter.

Also no changes are needed to the CSS from before.

And now we have the complete effect:

H
H
o
o
v
v
e
e
r
r
 
m
m
e
e

Timing and Easing

Although the effect is there. It is the timing and easing that makes all the difference.

Instead of using ease, we can use a cubic-bezier curve that gives a more snappy and responsive feel.

And we slow down the overall duration to 0.8s.

css
.button{
    transition: all 0.8s cubic-bezier(0.625, 0.05, 0, 1);   
}
.text{
    transition: all 0.8s cubic-bezier(0.625, 0.05, 0, 1);
}
.button{
    transition: all 0.8s cubic-bezier(0.625, 0.05, 0, 1);   
}
.text{
    transition: all 0.8s cubic-bezier(0.625, 0.05, 0, 1);
}

And now the final effect looks like this:

H
H
o
o
v
v
e
e
r
r
 
m
m
e
e

Complete React Implementation

Now that we know the pattern. Lets generalize it into a reusable React component.

jsx
import styles from "./Button.module.css";

export default function Button({text = "Hover me"}){
    const letters = text.split("");

    return (
        <div className={styles.button}>
            {letters.map((letter, index) => (
                <div className={styles.clipContainer} key={index}>
                    <div className={styles.text} style={{transitionDelay: `${index * 7}ms`}}>
                        {letter === ' ' ? '\u00A0' : letter}
                    </div>
                    <div className={styles.text} style={{transitionDelay: `${index * 7}ms`}}>
                        {letter === ' ' ? '\u00A0' : letter}
                    </div>
                </div>
            ))}
        </div>
    )
}
import styles from "./Button.module.css";

export default function Button({text = "Hover me"}){
    const letters = text.split("");

    return (
        <div className={styles.button}>
            {letters.map((letter, index) => (
                <div className={styles.clipContainer} key={index}>
                    <div className={styles.text} style={{transitionDelay: `${index * 7}ms`}}>
                        {letter === ' ' ? '\u00A0' : letter}
                    </div>
                    <div className={styles.text} style={{transitionDelay: `${index * 7}ms`}}>
                        {letter === ' ' ? '\u00A0' : letter}
                    </div>
                </div>
            ))}
        </div>
    )
}

{letter === ' ' ? '\u00A0' : letter} is used to replace spaces with non-breaking spaces so that they render correctly.

button.module.css
.button{
    font-family: 'Host Grotesk';
    font-size: 1.33rem;
    font-weight: 420;
    background-color: white;
    color: black;
    padding: 0.5rem 1rem;
    border-radius: 0.25rem;
    cursor: pointer;
    display: inline-flex;
    transition: all 0.8s cubic-bezier(0.625, 0.05, 0, 1);   
    /* box-shadow only for visibility on white backgrounds */
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
    --clipHeight: 1.4em;
}
.clipContainer{
    position: relative;
    display: inline-flex;
    height: var(--clipHeight);
    overflow: hidden;
    flex-direction: column;
}
.text{
    position: relative;
    display: inline-flex;
    align-items: center;
    height: var(--clipHeight);
    transition: all 0.8s cubic-bezier(0.625, 0.05, 0, 1);   
    transform: translateY(0);
}
.button:hover{
    scale: 0.95;
}
.button:hover .text{
    transform: translateY(calc(-1 * var(--clipHeight)));
}
.button{
    font-family: 'Host Grotesk';
    font-size: 1.33rem;
    font-weight: 420;
    background-color: white;
    color: black;
    padding: 0.5rem 1rem;
    border-radius: 0.25rem;
    cursor: pointer;
    display: inline-flex;
    transition: all 0.8s cubic-bezier(0.625, 0.05, 0, 1);   
    /* box-shadow only for visibility on white backgrounds */
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
    --clipHeight: 1.4em;
}
.clipContainer{
    position: relative;
    display: inline-flex;
    height: var(--clipHeight);
    overflow: hidden;
    flex-direction: column;
}
.text{
    position: relative;
    display: inline-flex;
    align-items: center;
    height: var(--clipHeight);
    transition: all 0.8s cubic-bezier(0.625, 0.05, 0, 1);   
    transform: translateY(0);
}
.button:hover{
    scale: 0.95;
}
.button:hover .text{
    transform: translateY(calc(-1 * var(--clipHeight)));
}

And here it is in action:

H
H
o
o
v
v
e
e
r
r
 
 
m
m
e
e

Bonus: Svelte Implementation

Here is a Svelte implementation of the same button component.

svelte
<script>
  let { text = "Hover me" } = $props();
</script>

<div class="button">
  {#each text.split('') as letter, index}
    <div class="clipContainer">
      <div class="text" style="transition-delay: {index * 7}ms">
        {letter === ' ' ? '\u00A0' : letter}
      </div>
      <div class="text" style="transition-delay: {index * 7}ms">
        {letter === ' ' ? '\u00A0' : letter}
      </div>
    </div>
  {/each}
</div>

<style>
  .button {
    font-family: 'Host Grotesk';
    font-size: 1.33rem;
    font-weight: 420;
    background-color: white;
    color: black;
    padding: 0.5rem 1rem;
    border-radius: 0.25rem;
    cursor: pointer;
    display: inline-flex;
    transition: all 0.8s cubic-bezier(0.625, 0.05, 0, 1);
    /* box-shadow only for visibility on white backgrounds */
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
    --clipHeight: 1.4em;
  }

  .clipContainer {
    position: relative;
    display: inline-flex;
    height: var(--clipHeight);
    overflow: hidden;
    flex-direction: column;
  }

  .text {
    position: relative;
    display: inline-flex;
    align-items: center;
    height: var(--clipHeight);
    transition: all 0.8s cubic-bezier(0.625, 0.05, 0, 1);
    transform: translateY(0);
  }

  .button:hover {
    scale: 0.95;
  }

  .button:hover .text {
    transform: translateY(calc(-1 * var(--clipHeight)));
  }
</style>
<script>
  let { text = "Hover me" } = $props();
</script>

<div class="button">
  {#each text.split('') as letter, index}
    <div class="clipContainer">
      <div class="text" style="transition-delay: {index * 7}ms">
        {letter === ' ' ? '\u00A0' : letter}
      </div>
      <div class="text" style="transition-delay: {index * 7}ms">
        {letter === ' ' ? '\u00A0' : letter}
      </div>
    </div>
  {/each}
</div>

<style>
  .button {
    font-family: 'Host Grotesk';
    font-size: 1.33rem;
    font-weight: 420;
    background-color: white;
    color: black;
    padding: 0.5rem 1rem;
    border-radius: 0.25rem;
    cursor: pointer;
    display: inline-flex;
    transition: all 0.8s cubic-bezier(0.625, 0.05, 0, 1);
    /* box-shadow only for visibility on white backgrounds */
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
    --clipHeight: 1.4em;
  }

  .clipContainer {
    position: relative;
    display: inline-flex;
    height: var(--clipHeight);
    overflow: hidden;
    flex-direction: column;
  }

  .text {
    position: relative;
    display: inline-flex;
    align-items: center;
    height: var(--clipHeight);
    transition: all 0.8s cubic-bezier(0.625, 0.05, 0, 1);
    transform: translateY(0);
  }

  .button:hover {
    scale: 0.95;
  }

  .button:hover .text {
    transform: translateY(calc(-1 * var(--clipHeight)));
  }
</style>
© 2025. Designed & Developed by Adam Sharif© 2025. Adam Sharif