<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', 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
This Pen doesn't use any external CSS resources.