<div class="demo"></div>
<a href="https://front-kek.com/demos/twitter-x-logo" target="_blank" class="tutorial-link">Tutorial</a>
<a href="https://twitter.com/NikolayTalanov/status/1195004656163807232" target="_blank" class="icon-link icon-link--twitter">
  <img src="https://cdn1.iconfinder.com/data/icons/logotypes/32/twitter-128.png">
</a>
// globals

* {
  box-sizing: border-box;
  position: relative;
  padding: 0;
  margin: 0;
}

html,
body {
  max-width: 100vw;
  overflow-x: hidden;
}

body {
  min-height: 100vh;
  background: #ededed;
  color: #333;
  line-height: 1.5;
  font-family: 'Roboto', -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif;
}

:root {
  --color-twitter: rgb(29, 155, 240);
}

a {
  color: inherit;
  text-decoration: none;
}

.demo {
  overflow: hidden;
  height: 100vh;
}

.tutorial-link {
  z-index: 100;
  position: absolute;
  left: 5px;
  bottom: 5px;
  color: #fff;
  text-transform: uppercase;
}

.icon-link {
  z-index: 100;
  position: absolute;
  right: 5px;
  bottom: 5px;
  width: 32px;

  img {
    width: 100%;
    vertical-align: top;
  }
}

// demo specific styles

.twitter-x {
  $parentRef: &;

  position: absolute;
  inset: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  background: #fff;

  @mixin isMorphing {
    #{$parentRef}.s--morphing & {
      @content;
    }
  }

  // all animations and transitions are applied only when the parent element has the class .s--morphing
  // this allows us to remove the class and instantly reset the UI to the initial state, without seeing any backwards animations

  // as for specific elements animations, then everything is somewhat straightforward:
  // there is a `--%name%-at` for animation/transition duration and `--%name%-delay` for delay, so they are always paired together

  &__center {
    width: var(--logo-size);
    height: var(--logo-size);
  }

  // scss generates random values on compilation which are allowing us to make it look like a random shaking,
  // by rapidly shifting element vertically and horizontally
  @keyframes shaking {
    @for $i from 0 through 50 {
      #{$i * 2%} { // interpolated results are 0%, 2%, ... 98%, 100%
        // (random(20) - 10) * 1px is a random value between -10px and 10px
        transform: translate((random(20) - 10) * 1px, (random(20) - 10) * 1px);
      } 
    }
  }

  &__logo {
    z-index: 2;
    position: absolute;
    inset: 0;

    @include isMorphing {
      animation: shaking var(--twitter-shaking-at) var(--twitter-shaking-delay);
    }
  
    &-svg {
      overflow: visible;
      stroke: var(--color-twitter);
      fill: var(--color-twitter);
      will-change: stroke, fill;

      @include isMorphing {
        transition: stroke var(--logo-morphing-at) var(--logo-morphing-delay), fill var(--logo-fill-at) var(--logo-fill-delay);
        fill: transparent;
        stroke: #fff;
      }
    }

    &-svg2 {
      overflow: visible;
      position: absolute;
      inset: 0;
      fill: #fff;

      path {
        transform: scale(0);

        // setting custom transform-origin is required to make the animation look like the line is being drawn from the center
        // ideally we would be using % values, but svg got this ancient trouble where transform-origin with % just doesn't works in many browsers
        // libraries like GSAP got their own logic to allow % values, which relies on some internal calculations, but we don't have such luxury here
        &:first-child {
          // keep in mind that this svg got viewBox="0 0 270 270", so px values are relative to these dimensions, unlike with twitter icon which is 24x24
          transform-origin: 100px 160px;
        }

        &:last-child {
          transform-origin: 160px 100px;
        }

        @include isMorphing {
          // cubic-bezier transition-timing-function allows you to create fancier animations, where elements could be bouncing or looking slightly elastic
          // you can check this playground to get the better idea https://cubic-bezier.com/
          transition: transform var(--x-part-2-at) var(--x-part-2-delay) cubic-bezier(0.13, 0.9, 0.3, 1.3);
          transform: scale(1);
        }
      }
    }
  }

  &__sweat {
    --timing: var(--twitter-reaction-at) var(--twitter-reaction-delay);

    position: absolute;
    right: 60px;
    top: 55px;
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    width: 60px;
    gap: 4px;
    color: white;
    transform: translateY(-10px);
    opacity: 0;
    will-change: opacity, transform;

    @include isMorphing {
      transition: opacity var(--timing), transform var(--timing);
      transform: translateY(0);
      opacity: 1;
    }

    svg {
      will-change: opacity;

      @include isMorphing {
        transition: opacity var(--logo-fill-at) var(--logo-fill-delay);
        opacity: 0;
      }
    }
  }

  &__elon {
    position: absolute;
    left: calc(100% + 20px);
    top: 50%;
    width: 300px;
    transform: translateY(-50%);
    opacity: 0;
    will-change: opacity;

    @media (max-width: 1100px) {
      width: 240px;
    }

    @media (max-width: 800px) {
      left: 50%;
      top: auto;
      bottom: 100%;
      width: 200px;
      transform: translate(-100px, 0);
    }

    @include isMorphing {
      transition: opacity var(--elon-appearance-at) var(--elon-appearance-delay);
      opacity: 1;
    }
  }

  // the black background is applied via "transform:scale" expansion of black circle in the middle of the demo, which covers whole demo container
  // it's a very cool technique which looks fancier than just changing background color the normal way, plus it allows you to cover other elements, like Elon's picture in our case
  // in order to ensure that it would be covering entire screen no matter what (if demo container would be unrestricted), we are using vmax units
  // and to be more specific we are setting 150vmax, which is 150% of the largest side of the screen
  // why 150? well, 1.44 is square root of 2, which is useful to know to understand basic geometry regarding triangles and shit, so that round shape could cover a square fully
  // so 150 is just a value with buffer above 144, it doesn't really matters that much
  &__black-bg {
    position: absolute;
    left: 50%;
    top: 50%;
    // 150vmax ensures that this element will cover the entire screen no matter what
    // (assuming that parent container doesn't have it's own overflow: hidden)
    width: 150vmax;
    height: 150vmax;
    margin-left: -75vmax;
    margin-top: -75vmax;
    border-radius: 50%;
    background: #000;
    transform: scale(0);
    will-change: transform;

    @include isMorphing {
      transition: transform var(--logo-morphing-at) var(--logo-morphing-delay);
      transform: scale(1);
    }
  }
  
  &__doge {
    position: absolute;
    left: calc(100% + 5px);
    bottom: calc(100% + 15px);
    width: 40px;
    transform: scale(0);

    @include isMorphing {
      transition: transform var(--doge-appearance-at) var(--doge-appearance-delay) cubic-bezier(0.13, 0.9, 0.4, 1.6);
      transform: scale(1);
    }
  }

  &__reset {
    position: absolute;
    left: 50%;
    top: calc(100% + 40px);
    width: 36px;
    height: 36px;
    margin-left: -18px;
    stroke: #fff;
    transform: scale(0);
    pointer-events: none;
    cursor: pointer;

    @include isMorphing {
      transition: transform var(--reset-appearance-at) var(--reset-appearance-delay) cubic-bezier(0.13, 0.9, 0.4, 1.6);
      transform: scale(1);
      pointer-events: auto;
    }
  }
}
View Compiled
import React, { useState, useEffect } from 'https://esm.sh/react@18.2.0'
import ReactDOM from 'https://esm.sh/react-dom@18.2.0'
import { IconBrandTwitter, IconDropletFilled, IconRefresh } from "https://cdn.skypack.dev/@tabler/icons-react@2.11.0";
import cn from "https://cdn.skypack.dev/classnames@2.3.2";

const { interpolate } = flubber;

// demo code

const logoSize = 270;
// original tabler twitter icon is contained in viewBox="0 0 24 24" so I'm using the same coordinates system to create that X rectangle shape as target for morphing
const targetPath = 'M0,0 6,0 24,24 18,24Z';

// this is our single source of truth for chain of animations timings, values are in seconds
const animations = [
  { name: 'elon-waiting', duration: 0.5 },
  { name: 'elon-appearance', duration: 1 },
  { name: 'twitter-reaction-waiting', duration: 0.3 },
  { name: 'twitter-reaction', duration: 0.7 },
  { name: 'twitter-shaking', duration: 1.4 },
  { name: 'logo-fill-waiting', duration: 0 },
  { name: 'logo-fill', duration: 0.1 },
  { name: 'logo-morphing', duration: 0.2 }, // this step combines black background circle expansion and twitter logo morphing with stroke color change
  { name: 'x-part-2', duration: 0.6 },
  { name: 'doge-appearance', duration: 0.3 },
  { name: 'reset-appearance', duration: 0.3 },
];

// this map also contains delays for each animation, which makes our css transitions code very trivial
const { acc: animationsWithDelaysMap } = animations.reduce(
  ({ acc, delay }, anim) => {
    acc[anim.name] = anim.duration;
    acc[`${anim.name}-delay`] = delay;
    return {
      acc, // accumulates animation durations and their respective delays
      delay: delay + anim.duration, // accumulates total delay
    };
  },
  { acc: {} as Record<string, number>, delay: 0 }
);

interface Props {
  onReset: () => void;
}

function TwitterXLogoDemo({ onReset }: Props) {
  const [isMorphing, setIsMorphing] = useState(false);

  // this useEffect runs once at the start of component's initialization (which is also being triggered by key prop change)
  useEffect(() => {
    setIsMorphing(true);

    const colorChangeAnim = animationsWithDelaysMap['logo-morphing'] * 1000;
    const colorChangeDelay =
      animationsWithDelaysMap['logo-morphing-delay'] * 1000;

    // using good old dom selector, nothing fancy
    // but in a more serious project I would use useRef hook to get a reference to this element to evade relying on global classes
    const $path = document.querySelector('.twitter-x__logo-svg path');
    const twitterPath = $path?.getAttribute('d') || '';

    // I'm using flubber library (https://github.com/veltman/flubber) to morph twitter svg into X rectangle
    // GSAP MorphSVGPlugin is most likely is a better choice, but it requires paid membership to use it
    const interpolator = interpolate(twitterPath, targetPath);

    // I'm creating startTime variable here and not in timeout because of annoying js closure behavior
    const startTime = Date.now();

    let linejoinChanged = false;

    setTimeout(() => {
      // check mdn https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame for api reference
      // but overall tldr is that rAF runs callback function on next frame, which is usually 60 times per second
      requestAnimationFrame(draw);

      function draw() {
        // since startTime is defined outside of this function, we need to subtract the delay also to get proper elapsed time
        const elapsed = Date.now() - startTime - colorChangeDelay;
        const p = elapsed / colorChangeAnim; // progress of animation, from 0 to 1
        const d = interpolator(p);

        $path?.setAttribute('d', d);

        if (p < 1) {
          // run this function in rAF loop until animation is finished
          requestAnimationFrame(draw);
        }

        if (p >= 0.5 && !linejoinChanged) {
          // twitter icon got round linejoin by default to make it look smoother,
          // but X rectangle requires sharp corners, so this part changes it mid-animation
          linejoinChanged = true;
          $path?.setAttribute('stroke-linejoin', 'miter');
        }
      }
    }, colorChangeDelay);
  }, []); // empty array dependency means that this effect will run only once

  const styleObj = {
    '--logo-size': `${logoSize}px`,
    ...Object.entries(animationsWithDelaysMap).reduce(
      (acc, [name, duration]) => {
        // the final result is something like { '--doge-appearance-at': '0.3s', '--doge-appearance-delay': '1.5s' }
        acc[`--${name}${name.endsWith('delay') ? '' : '-at'}`] = `${duration}s`;
        return acc;
      },
      {} as Record<string, string>
    ),
  } as React.CSSProperties;

  return (
    <div
      className={cn('twitter-x', { 's--morphing': isMorphing })}
      style={styleObj}
    >
      <div className="twitter-x__center">
        <div className="twitter-x__logo">
          {/* I'm using tabler icons which are based on 24x24 viewBox,
          so values for things like stroke are relative to that original size */}
          <IconBrandTwitter
            size={logoSize}
            stroke="1.5"
            className="twitter-x__logo-svg"
          />

          {/* Second part of X logo, the line from bottom-left corner to top-right.
          But actually it's 2 lines in our case. Painted with numbers :) */}
          <svg viewBox="0 0 270 270" className="twitter-x__logo-svg2">
            <path d="M-20,280 0,280 122,153 102,150z" />
            <path d="M250,-10 270,-10 160,115 150,100z" />
          </svg>

          {/* I'm nesting it in a container so that I could hide svg droplet later with separate transition, without using second class */}
          <div className="twitter-x__sweat">
            <IconDropletFilled size={24} />
          </div>
        </div>
        <img
          src="https://i.imgur.com/97TTsIS.png"
          alt="Elon Smoking"
          className="twitter-x__elon"
        />
        <div className="twitter-x__black-bg" />
        <img
          src="https://i.imgur.com/NP1T6VA.png"
          alt="Doge"
          className="twitter-x__doge"
        />
        <IconRefresh className="twitter-x__reset" onClick={onReset} />
      </div>
    </div>
  );
}

// we are using this wrapper to reset our component state and rerun useEffect by changing the key prop
export default function ResetWrapper() {
  const [refreshMs, setRefreshMs] = useState(0);

  return (
    <TwitterXLogoDemo
      key={refreshMs}
      onReset={() => setRefreshMs(Date.now())} // using timestamp is probably the most braindead and bulletproof way to get new unique key each time
    />
  );
}

ReactDOM.render(<ResetWrapper />, document.querySelector('.demo'));
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/flubber/0.4.2/flubber.min.js