-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdemo.tsx
More file actions
179 lines (156 loc) · 6.63 KB
/
demo.tsx
File metadata and controls
179 lines (156 loc) · 6.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
'use client';
import { useState, useEffect } from 'react';
import cn from 'classnames';
import {
IconBrandTwitter,
IconDropletFilled,
IconRefresh,
} from '@tabler/icons-react';
//@ts-ignore
import { interpolate } from 'flubber';
import { DemoContainer } from 'src/components';
import './styles.scss';
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
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
/>
);
}
export default function Demo() {
return (
<DemoContainer
component={ResetWrapper}
calloutStyle={{ color: '#fff' }}
style={{ overflow: 'hidden' }}
/>
);
}