tangled
alpha
login
or
join now
danielmorrisey.com
/
seo-tester
0
fork
atom
A simple SEO inspecter Tool, to get social media card previews
0
fork
atom
overview
issues
pulls
pipelines
Add design customization features
gpt-engineer-app[bot]
6 months ago
0e638dec
5fc1e31c
+322
-68
3 changed files
expand all
collapse all
unified
split
src
components
SEOTester.tsx
index.css
tailwind.config.ts
+271
-56
src/components/SEOTester.tsx
···
1
1
-
import React, { useState } from 'react';
1
1
+
import React, { useState, useEffect } from 'react';
2
2
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
3
3
import { Button } from '@/components/ui/button';
4
4
import { Input } from '@/components/ui/input';
5
5
import { Badge } from '@/components/ui/badge';
6
6
import { Separator } from '@/components/ui/separator';
7
7
+
import { Progress } from '@/components/ui/progress';
7
8
import { useToast } from '@/hooks/use-toast';
8
8
-
import { Search, ExternalLink, Image, FileText, Tag, Globe, AlertCircle, CheckCircle } from 'lucide-react';
9
9
+
import { Search, ExternalLink, Image, FileText, Tag, Globe, AlertCircle, CheckCircle, Zap, TrendingUp, Eye, Share2, Target } from 'lucide-react';
9
10
10
11
interface SEOData {
11
12
title?: string;
···
21
22
canonical?: string;
22
23
keywords?: string;
23
24
url: string;
25
25
+
h1?: string;
26
26
+
metaRobots?: string;
27
27
+
lang?: string;
28
28
+
viewport?: string;
29
29
+
charset?: string;
30
30
+
}
31
31
+
32
32
+
interface SEOScore {
33
33
+
total: number;
34
34
+
breakdown: {
35
35
+
basic: number;
36
36
+
social: number;
37
37
+
technical: number;
38
38
+
};
24
39
}
25
40
26
41
const SEOTester = () => {
···
28
43
const [isLoading, setIsLoading] = useState(false);
29
44
const [seoData, setSeoData] = useState<SEOData | null>(null);
30
45
const [error, setError] = useState<string | null>(null);
46
46
+
const [progress, setProgress] = useState(0);
47
47
+
const [seoScore, setSeoScore] = useState<SEOScore | null>(null);
48
48
+
const [showResults, setShowResults] = useState(false);
31
49
const { toast } = useToast();
32
50
33
51
const extractMetaData = (html: string, url: string): SEOData => {
···
46
64
47
65
const title = doc.querySelector('title')?.textContent || '';
48
66
const canonical = doc.querySelector('link[rel="canonical"]')?.getAttribute('href') || '';
67
67
+
const h1 = doc.querySelector('h1')?.textContent || '';
68
68
+
const htmlElement = doc.querySelector('html');
69
69
+
const lang = htmlElement?.getAttribute('lang') || '';
49
70
50
71
return {
51
72
title,
···
60
81
twitterImage: getMetaContent('twitter:image'),
61
82
canonical,
62
83
keywords: getMetaContent('keywords'),
84
84
+
h1,
85
85
+
metaRobots: getMetaContent('robots'),
86
86
+
lang,
87
87
+
viewport: getMetaContent('viewport'),
88
88
+
charset: doc.querySelector('meta[charset]')?.getAttribute('charset') || '',
63
89
url
64
90
};
65
91
};
66
92
93
93
+
const calculateSEOScore = (data: SEOData): SEOScore => {
94
94
+
let basicScore = 0;
95
95
+
let socialScore = 0;
96
96
+
let technicalScore = 0;
97
97
+
98
98
+
// Basic SEO (40 points max)
99
99
+
if (data.title) basicScore += 15;
100
100
+
if (data.description) basicScore += 15;
101
101
+
if (data.h1) basicScore += 10;
102
102
+
103
103
+
// Social Media (30 points max)
104
104
+
if (data.ogTitle || data.title) socialScore += 8;
105
105
+
if (data.ogDescription || data.description) socialScore += 8;
106
106
+
if (data.ogImage) socialScore += 14;
107
107
+
108
108
+
// Technical SEO (30 points max)
109
109
+
if (data.canonical) technicalScore += 10;
110
110
+
if (data.lang) technicalScore += 5;
111
111
+
if (data.viewport) technicalScore += 5;
112
112
+
if (data.charset) technicalScore += 5;
113
113
+
if (!data.metaRobots || !data.metaRobots.includes('noindex')) technicalScore += 5;
114
114
+
115
115
+
const total = basicScore + socialScore + technicalScore;
116
116
+
117
117
+
return {
118
118
+
total,
119
119
+
breakdown: {
120
120
+
basic: basicScore,
121
121
+
social: socialScore,
122
122
+
technical: technicalScore
123
123
+
}
124
124
+
};
125
125
+
};
126
126
+
67
127
const analyzeSEO = async () => {
68
128
if (!url) {
69
129
toast({
···
79
139
setSeoData(null);
80
140
81
141
try {
142
142
+
// Simulate progress updates
143
143
+
setProgress(20);
144
144
+
82
145
// First, let's try to validate the URL format
83
146
const urlObj = new URL(url);
147
147
+
setProgress(40);
84
148
85
149
// For demonstration, we'll use a CORS proxy service
86
150
// In a real app, you'd want to use a backend service
87
151
const proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`;
88
152
const response = await fetch(proxyUrl);
153
153
+
setProgress(70);
89
154
90
155
if (!response.ok) {
91
156
throw new Error(`HTTP error! status: ${response.status}`);
···
97
162
throw new Error('No content received from the website');
98
163
}
99
164
165
165
+
setProgress(90);
100
166
const extractedData = extractMetaData(data.contents, url);
167
167
+
const score = calculateSEOScore(extractedData);
168
168
+
101
169
setSeoData(extractedData);
170
170
+
setSeoScore(score);
171
171
+
setProgress(100);
172
172
+
173
173
+
// Trigger results animation
174
174
+
setTimeout(() => {
175
175
+
setShowResults(true);
176
176
+
}, 200);
102
177
103
178
toast({
104
179
title: "Success",
···
124
199
});
125
200
} finally {
126
201
setIsLoading(false);
202
202
+
if (!error) {
203
203
+
setTimeout(() => setProgress(0), 1000);
204
204
+
}
127
205
}
128
206
};
129
207
···
132
210
analyzeSEO();
133
211
};
134
212
135
135
-
const getScoreColor = (hasValue: boolean) => {
136
136
-
return hasValue ? 'success' : 'destructive';
213
213
+
const getScoreColor = (score: number) => {
214
214
+
if (score >= 80) return 'success';
215
215
+
if (score >= 60) return 'warning';
216
216
+
return 'destructive';
217
217
+
};
218
218
+
219
219
+
const getScoreGrade = (score: number) => {
220
220
+
if (score >= 90) return 'A+';
221
221
+
if (score >= 80) return 'A';
222
222
+
if (score >= 70) return 'B';
223
223
+
if (score >= 60) return 'C';
224
224
+
if (score >= 50) return 'D';
225
225
+
return 'F';
137
226
};
138
227
139
228
const ScoreIndicator = ({ hasValue, label }: { hasValue: boolean; label: string }) => (
140
140
-
<div className="flex items-center gap-2">
141
141
-
{hasValue ? (
142
142
-
<CheckCircle className="h-4 w-4 text-success" />
143
143
-
) : (
144
144
-
<AlertCircle className="h-4 w-4 text-destructive" />
145
145
-
)}
146
146
-
<span className="text-sm">{label}</span>
229
229
+
<div className="flex items-center gap-2 group cursor-default">
230
230
+
<div className="relative">
231
231
+
{hasValue ? (
232
232
+
<CheckCircle className="h-4 w-4 text-success group-hover:scale-110 transition-transform duration-200" />
233
233
+
) : (
234
234
+
<AlertCircle className="h-4 w-4 text-destructive group-hover:scale-110 transition-transform duration-200" />
235
235
+
)}
236
236
+
</div>
237
237
+
<span className="text-sm group-hover:text-foreground transition-colors duration-200">{label}</span>
147
238
</div>
148
239
);
149
240
241
241
+
const AnimatedCounter = ({ value, duration = 1000 }: { value: number; duration?: number }) => {
242
242
+
const [count, setCount] = useState(0);
243
243
+
244
244
+
useEffect(() => {
245
245
+
if (showResults) {
246
246
+
let start = 0;
247
247
+
const increment = value / (duration / 16);
248
248
+
const timer = setInterval(() => {
249
249
+
start += increment;
250
250
+
if (start >= value) {
251
251
+
setCount(value);
252
252
+
clearInterval(timer);
253
253
+
} else {
254
254
+
setCount(Math.floor(start));
255
255
+
}
256
256
+
}, 16);
257
257
+
return () => clearInterval(timer);
258
258
+
}
259
259
+
}, [value, duration, showResults]);
260
260
+
261
261
+
return <span>{count}</span>;
262
262
+
};
263
263
+
150
264
return (
151
151
-
<div className="min-h-screen bg-gradient-subtle">
152
152
-
<div className="container mx-auto px-4 py-8">
265
265
+
<div className="min-h-screen bg-gradient-subtle relative overflow-hidden">
266
266
+
{/* Animated background elements */}
267
267
+
<div className="absolute inset-0 overflow-hidden pointer-events-none">
268
268
+
<div className="absolute -top-40 -right-40 w-80 h-80 bg-primary/5 rounded-full animate-pulse-glow"></div>
269
269
+
<div className="absolute -bottom-40 -left-40 w-96 h-96 bg-primary-glow/5 rounded-full animate-pulse-glow" style={{animationDelay: '1s'}}></div>
270
270
+
</div>
271
271
+
272
272
+
<div className="container mx-auto px-4 py-8 relative z-10">
153
273
<div className="max-w-4xl mx-auto">
154
274
{/* Header */}
155
155
-
<div className="text-center mb-8">
156
156
-
<h1 className="text-4xl font-bold text-foreground mb-4">SEO Analyzer</h1>
157
157
-
<p className="text-lg text-muted-foreground">
158
158
-
Analyze any website's SEO metadata and social media tags
275
275
+
<div className="text-center mb-8 animate-fade-in">
276
276
+
<div className="inline-flex items-center gap-2 mb-4">
277
277
+
<div className="p-2 bg-gradient-primary rounded-xl shadow-elegant">
278
278
+
<Target className="h-6 w-6 text-white" />
279
279
+
</div>
280
280
+
<h1 className="text-4xl font-bold text-foreground">SEO Analyzer</h1>
281
281
+
</div>
282
282
+
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
283
283
+
Get comprehensive SEO insights with our advanced analyzer.
284
284
+
Discover optimization opportunities and track your website's performance.
159
285
</p>
160
286
</div>
161
287
162
288
{/* URL Input Form */}
163
163
-
<Card className="mb-8 shadow-card">
289
289
+
<Card className="mb-8 shadow-card animate-scale-in border-0 bg-card/80 backdrop-blur-sm">
164
290
<CardContent className="pt-6">
165
165
-
<form onSubmit={handleSubmit} className="flex gap-4">
166
166
-
<div className="flex-1">
167
167
-
<Input
168
168
-
type="url"
169
169
-
placeholder="https://example.com"
170
170
-
value={url}
171
171
-
onChange={(e) => setUrl(e.target.value)}
172
172
-
className="text-lg"
173
173
-
required
174
174
-
/>
291
291
+
<form onSubmit={handleSubmit} className="space-y-4">
292
292
+
<div className="flex gap-4">
293
293
+
<div className="flex-1 relative">
294
294
+
<Input
295
295
+
type="url"
296
296
+
placeholder="https://example.com"
297
297
+
value={url}
298
298
+
onChange={(e) => setUrl(e.target.value)}
299
299
+
className="text-lg pr-12 border-2 focus:border-primary transition-all duration-300"
300
300
+
required
301
301
+
/>
302
302
+
<Globe className="absolute right-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-muted-foreground" />
303
303
+
</div>
304
304
+
<Button
305
305
+
type="submit"
306
306
+
size="lg"
307
307
+
disabled={isLoading}
308
308
+
className="bg-gradient-primary hover:shadow-elegant transition-all duration-300 hover:scale-105 relative overflow-hidden group"
309
309
+
>
310
310
+
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700 ease-in-out"></div>
311
311
+
{isLoading ? (
312
312
+
<>
313
313
+
<Zap className="h-4 w-4 mr-2 animate-bounce-subtle" />
314
314
+
Analyzing...
315
315
+
</>
316
316
+
) : (
317
317
+
<>
318
318
+
<Search className="h-4 w-4 mr-2" />
319
319
+
Analyze SEO
320
320
+
</>
321
321
+
)}
322
322
+
</Button>
175
323
</div>
176
176
-
<Button
177
177
-
type="submit"
178
178
-
size="lg"
179
179
-
disabled={isLoading}
180
180
-
className="bg-gradient-primary hover:shadow-elegant transition-all duration-300"
181
181
-
>
182
182
-
<Search className="h-4 w-4 mr-2" />
183
183
-
{isLoading ? 'Analyzing...' : 'Analyze'}
184
184
-
</Button>
324
324
+
325
325
+
{isLoading && (
326
326
+
<div className="space-y-2 animate-fade-in">
327
327
+
<div className="flex items-center justify-between text-sm">
328
328
+
<span className="text-muted-foreground">Analyzing website...</span>
329
329
+
<span className="text-primary font-medium">{progress}%</span>
330
330
+
</div>
331
331
+
<Progress value={progress} className="h-2" />
332
332
+
</div>
333
333
+
)}
185
334
</form>
186
335
</CardContent>
187
336
</Card>
···
199
348
)}
200
349
201
350
{/* Results */}
202
202
-
{seoData && (
203
203
-
<div className="space-y-6">
204
204
-
{/* SEO Score Overview */}
205
205
-
<Card className="shadow-card">
351
351
+
{seoData && seoScore && (
352
352
+
<div className={`space-y-6 ${showResults ? 'animate-fade-in-up' : 'opacity-0'}`}>
353
353
+
{/* SEO Score Dashboard */}
354
354
+
<Card className="shadow-card border-0 bg-gradient-primary text-white relative overflow-hidden">
355
355
+
<div className="absolute inset-0 bg-gradient-to-br from-primary via-primary-glow to-primary opacity-90"></div>
356
356
+
<CardContent className="pt-6 relative z-10">
357
357
+
<div className="text-center mb-6">
358
358
+
<div className="inline-flex items-center gap-3 mb-4">
359
359
+
<TrendingUp className="h-8 w-8" />
360
360
+
<h2 className="text-2xl font-bold">SEO Score</h2>
361
361
+
</div>
362
362
+
<div className="relative inline-flex items-center justify-center">
363
363
+
<div className="text-6xl font-bold mb-2">
364
364
+
<AnimatedCounter value={seoScore.total} />
365
365
+
</div>
366
366
+
<div className="absolute -top-2 -right-8 text-lg font-medium bg-white/20 px-2 py-1 rounded-full">
367
367
+
{getScoreGrade(seoScore.total)}
368
368
+
</div>
369
369
+
</div>
370
370
+
<p className="text-white/80 mb-6">out of 100 points</p>
371
371
+
</div>
372
372
+
373
373
+
<div className="grid grid-cols-3 gap-4 text-center">
374
374
+
<div className="bg-white/10 rounded-lg p-4">
375
375
+
<div className="text-2xl font-bold mb-1">
376
376
+
<AnimatedCounter value={seoScore.breakdown.basic} />
377
377
+
</div>
378
378
+
<div className="text-sm text-white/80">Basic SEO</div>
379
379
+
<div className="text-xs text-white/60">/ 40 pts</div>
380
380
+
</div>
381
381
+
<div className="bg-white/10 rounded-lg p-4">
382
382
+
<div className="text-2xl font-bold mb-1">
383
383
+
<AnimatedCounter value={seoScore.breakdown.social} />
384
384
+
</div>
385
385
+
<div className="text-sm text-white/80">Social Media</div>
386
386
+
<div className="text-xs text-white/60">/ 30 pts</div>
387
387
+
</div>
388
388
+
<div className="bg-white/10 rounded-lg p-4">
389
389
+
<div className="text-2xl font-bold mb-1">
390
390
+
<AnimatedCounter value={seoScore.breakdown.technical} />
391
391
+
</div>
392
392
+
<div className="text-sm text-white/80">Technical</div>
393
393
+
<div className="text-xs text-white/60">/ 30 pts</div>
394
394
+
</div>
395
395
+
</div>
396
396
+
</CardContent>
397
397
+
</Card>
398
398
+
399
399
+
{/* Quick Overview */}
400
400
+
<Card className="shadow-card border-0 bg-card/80 backdrop-blur-sm">
206
401
<CardHeader>
207
402
<CardTitle className="flex items-center gap-2">
208
208
-
<Tag className="h-5 w-5" />
209
209
-
SEO Overview
403
403
+
<Eye className="h-5 w-5" />
404
404
+
Quick Overview
210
405
</CardTitle>
211
406
</CardHeader>
212
407
<CardContent>
···
215
410
<ScoreIndicator hasValue={!!seoData.description} label="Meta Description" />
216
411
<ScoreIndicator hasValue={!!seoData.ogImage} label="OG Image" />
217
412
<ScoreIndicator hasValue={!!seoData.canonical} label="Canonical URL" />
413
413
+
<ScoreIndicator hasValue={!!seoData.h1} label="H1 Tag" />
414
414
+
<ScoreIndicator hasValue={!!seoData.lang} label="Language" />
415
415
+
<ScoreIndicator hasValue={!!seoData.viewport} label="Viewport" />
416
416
+
<ScoreIndicator hasValue={!!seoData.charset} label="Charset" />
218
417
</div>
219
418
</CardContent>
220
419
</Card>
221
420
222
421
{/* Basic SEO */}
223
223
-
<Card className="shadow-card">
422
422
+
<Card className="shadow-card border-0 bg-card/80 backdrop-blur-sm">
224
423
<CardHeader>
225
424
<CardTitle className="flex items-center gap-2">
226
226
-
<FileText className="h-5 w-5" />
425
425
+
<FileText className="h-5 w-5 text-primary" />
227
426
Basic SEO
427
427
+
<Badge variant="secondary" className="ml-auto">
428
428
+
{seoScore.breakdown.basic}/40 pts
429
429
+
</Badge>
228
430
</CardTitle>
229
431
</CardHeader>
230
230
-
<CardContent className="space-y-4">
231
231
-
<div>
232
232
-
<label className="text-sm font-medium text-muted-foreground">Page Title</label>
233
233
-
<p className="mt-1 text-foreground">{seoData.title || 'Not found'}</p>
432
432
+
<CardContent className="space-y-6">
433
433
+
<div className="group hover:bg-muted/30 p-3 rounded-lg transition-colors duration-200">
434
434
+
<label className="text-sm font-medium text-muted-foreground flex items-center gap-2">
435
435
+
<Tag className="h-3 w-3" />
436
436
+
Page Title
437
437
+
{seoData.title && (
438
438
+
<Badge variant={seoData.title.length >= 30 && seoData.title.length <= 60 ? 'default' : 'secondary'} className="text-xs">
439
439
+
{seoData.title.length} chars
440
440
+
</Badge>
441
441
+
)}
442
442
+
</label>
443
443
+
<p className="mt-2 text-foreground font-medium">{seoData.title || 'Not found'}</p>
234
444
{seoData.title && (
235
235
-
<p className="text-xs text-muted-foreground mt-1">
236
236
-
Length: {seoData.title.length} characters
237
237
-
</p>
445
445
+
<div className="mt-2 text-xs text-muted-foreground">
446
446
+
{seoData.title.length < 30 && <span className="text-yellow-600">⚠️ Too short (recommended: 30-60 characters)</span>}
447
447
+
{seoData.title.length > 60 && <span className="text-yellow-600">⚠️ Too long (recommended: 30-60 characters)</span>}
448
448
+
{seoData.title.length >= 30 && seoData.title.length <= 60 && <span className="text-green-600">✓ Good length</span>}
449
449
+
</div>
238
450
)}
239
451
</div>
240
452
···
280
492
</Card>
281
493
282
494
{/* Open Graph */}
283
283
-
<Card className="shadow-card">
495
495
+
<Card className="shadow-card border-0 bg-card/80 backdrop-blur-sm">
284
496
<CardHeader>
285
497
<CardTitle className="flex items-center gap-2">
286
286
-
<Globe className="h-5 w-5" />
498
498
+
<Share2 className="h-5 w-5 text-primary" />
287
499
Open Graph (Facebook)
500
500
+
<Badge variant="secondary" className="ml-auto">
501
501
+
{seoScore.breakdown.social}/30 pts
502
502
+
</Badge>
288
503
</CardTitle>
289
504
</CardHeader>
290
505
<CardContent className="space-y-4">
···
325
540
</Card>
326
541
327
542
{/* Twitter Cards */}
328
328
-
<Card className="shadow-card">
543
543
+
<Card className="shadow-card border-0 bg-card/80 backdrop-blur-sm">
329
544
<CardHeader>
330
545
<CardTitle className="flex items-center gap-2">
331
331
-
<Image className="h-5 w-5" />
546
546
+
<Image className="h-5 w-5 text-primary" />
332
547
Twitter Cards
333
548
</CardTitle>
334
549
</CardHeader>
+12
src/index.css
···
47
47
/* Shadows */
48
48
--shadow-elegant: 0 10px 30px -10px hsl(var(--primary) / 0.2);
49
49
--shadow-card: 0 4px 12px -2px hsl(215 25% 27% / 0.08);
50
50
+
--shadow-glow: 0 0 40px hsl(var(--primary) / 0.3);
51
51
+
52
52
+
/* Animations */
53
53
+
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
54
54
+
--transition-bounce: all 0.2s cubic-bezier(0.68, -0.55, 0.265, 1.55);
55
55
+
56
56
+
--shimmer-bg: linear-gradient(
57
57
+
90deg,
58
58
+
transparent,
59
59
+
hsl(var(--primary) / 0.1),
60
60
+
transparent
61
61
+
);
50
62
51
63
--radius: 0.5rem;
52
64
+39
-12
tailwind.config.ts
···
78
78
},
79
79
keyframes: {
80
80
"accordion-down": {
81
81
-
from: {
82
82
-
height: "0",
83
83
-
},
84
84
-
to: {
85
85
-
height: "var(--radix-accordion-content-height)",
86
86
-
},
81
81
+
from: { height: "0", opacity: "0" },
82
82
+
to: { height: "var(--radix-accordion-content-height)", opacity: "1" }
87
83
},
88
84
"accordion-up": {
89
89
-
from: {
90
90
-
height: "var(--radix-accordion-content-height)",
91
91
-
},
92
92
-
to: {
93
93
-
height: "0",
94
94
-
},
85
85
+
from: { height: "var(--radix-accordion-content-height)", opacity: "1" },
86
86
+
to: { height: "0", opacity: "0" }
87
87
+
},
88
88
+
"fade-in": {
89
89
+
"0%": { opacity: "0", transform: "translateY(10px)" },
90
90
+
"100%": { opacity: "1", transform: "translateY(0)" }
91
91
+
},
92
92
+
"fade-in-up": {
93
93
+
"0%": { opacity: "0", transform: "translateY(20px)" },
94
94
+
"100%": { opacity: "1", transform: "translateY(0)" }
95
95
+
},
96
96
+
"scale-in": {
97
97
+
"0%": { transform: "scale(0.95)", opacity: "0" },
98
98
+
"100%": { transform: "scale(1)", opacity: "1" }
99
99
+
},
100
100
+
"slide-in-right": {
101
101
+
"0%": { transform: "translateX(100%)", opacity: "0" },
102
102
+
"100%": { transform: "translateX(0)", opacity: "1" }
103
103
+
},
104
104
+
"pulse-glow": {
105
105
+
"0%, 100%": { boxShadow: "0 0 20px hsl(var(--primary) / 0.3)" },
106
106
+
"50%": { boxShadow: "0 0 40px hsl(var(--primary) / 0.6)" }
107
107
+
},
108
108
+
"bounce-subtle": {
109
109
+
"0%, 100%": { transform: "translateY(0)" },
110
110
+
"50%": { transform: "translateY(-2px)" }
95
111
},
112
112
+
"shimmer": {
113
113
+
"0%": { backgroundPosition: "-200% 0" },
114
114
+
"100%": { backgroundPosition: "200% 0" }
115
115
+
}
96
116
},
97
117
animation: {
98
118
"accordion-down": "accordion-down 0.2s ease-out",
99
119
"accordion-up": "accordion-up 0.2s ease-out",
120
120
+
"fade-in": "fade-in 0.3s ease-out",
121
121
+
"fade-in-up": "fade-in-up 0.4s ease-out",
122
122
+
"scale-in": "scale-in 0.2s ease-out",
123
123
+
"slide-in-right": "slide-in-right 0.3s ease-out",
124
124
+
"pulse-glow": "pulse-glow 2s infinite",
125
125
+
"bounce-subtle": "bounce-subtle 2s infinite",
126
126
+
"shimmer": "shimmer 2s infinite linear"
100
127
},
101
128
},
102
129
},