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