@olwiba/ui 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/dist/index.d.ts +762 -0
  4. package/dist/index.js +2102 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +88 -0
  7. package/src/app/AppShell.tsx +365 -0
  8. package/src/app/AuthSection.tsx +141 -0
  9. package/src/app/EmptyState.tsx +34 -0
  10. package/src/app/ErrorPage.tsx +78 -0
  11. package/src/app/UpgradePrompt.tsx +203 -0
  12. package/src/blog/PostCard.tsx +66 -0
  13. package/src/blog/PostList.tsx +27 -0
  14. package/src/components/ConfirmDialog.tsx +43 -0
  15. package/src/components/ContextMenu.tsx +86 -0
  16. package/src/components/DevBanner.tsx +22 -0
  17. package/src/components/Dock.tsx +94 -0
  18. package/src/components/FeatureCard.tsx +45 -0
  19. package/src/components/GlassCard.tsx +30 -0
  20. package/src/components/ImageCard.tsx +60 -0
  21. package/src/components/PageHeader.tsx +101 -0
  22. package/src/components/PricingCard.tsx +90 -0
  23. package/src/components/RegisterHotkeys.tsx +43 -0
  24. package/src/components/RootErrorFallback.tsx +29 -0
  25. package/src/components/Spinner.tsx +14 -0
  26. package/src/components/Spotlight.tsx +104 -0
  27. package/src/components/StatCard.tsx +44 -0
  28. package/src/components/Suspensed.tsx +17 -0
  29. package/src/components/TestimonialCard.tsx +64 -0
  30. package/src/components/ThemeColorUpdater.tsx +23 -0
  31. package/src/components/ThemeSwitchMinimal.tsx +28 -0
  32. package/src/components/VersionBanner.tsx +38 -0
  33. package/src/context/OlwibaUIContext.tsx +58 -0
  34. package/src/hooks/use-confirm.ts +64 -0
  35. package/src/hooks/use-controlled-open.ts +33 -0
  36. package/src/hooks/use-copy-to-clipboard.ts +20 -0
  37. package/src/hooks/use-debounce.ts +14 -0
  38. package/src/hooks/use-intersection-observer.ts +25 -0
  39. package/src/hooks/use-local-storage.ts +31 -0
  40. package/src/hooks/use-media-query.ts +21 -0
  41. package/src/hooks/use-mounted.ts +11 -0
  42. package/src/hooks/use-pagination.ts +31 -0
  43. package/src/hooks/use-scrolled-past.ts +27 -0
  44. package/src/index.ts +113 -0
  45. package/src/lib/utils.ts +6 -0
  46. package/src/marketing/ContactSection.tsx +110 -0
  47. package/src/marketing/CtaSection.tsx +74 -0
  48. package/src/marketing/FaqSection.tsx +44 -0
  49. package/src/marketing/FeaturesSection.tsx +42 -0
  50. package/src/marketing/Footer.tsx +87 -0
  51. package/src/marketing/HeroSection.tsx +112 -0
  52. package/src/marketing/LogoStrip.tsx +94 -0
  53. package/src/marketing/Navbar.tsx +142 -0
  54. package/src/marketing/NewsletterSection.tsx +64 -0
  55. package/src/marketing/PricingSection.tsx +120 -0
  56. package/src/marketing/SectionTitle.tsx +30 -0
  57. package/src/marketing/StatsSection.tsx +63 -0
  58. package/src/marketing/TeamSection.tsx +110 -0
  59. package/src/marketing/TestimonialsSection.tsx +56 -0
  60. package/src/motion/CountUp.tsx +64 -0
  61. package/src/motion/FadeIn.tsx +69 -0
  62. package/src/motion/PageTransition.tsx +49 -0
  63. package/src/motion/StaggerChildren.tsx +74 -0
  64. package/src/overlays/Overlay.tsx +88 -0
  65. package/src/overlays/Underlay.tsx +114 -0
  66. package/src/primitives/Badge.tsx +11 -0
  67. package/src/primitives/Button.tsx +16 -0
  68. package/src/primitives/Card.tsx +22 -0
  69. package/src/primitives/Checkbox.tsx +17 -0
  70. package/src/primitives/Input.tsx +16 -0
  71. package/src/primitives/Switch.tsx +16 -0
  72. package/src/primitives/Textarea.tsx +16 -0
  73. package/src/primitives/index.ts +7 -0
  74. package/src/types/external-packages.d.ts +79 -0
@@ -0,0 +1,120 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Badge, cn } from '@olwiba/cn';
5
+ import { PricingCard, type PricingFeature } from '../components/PricingCard';
6
+ import { StaggerChildren } from '../motion/StaggerChildren';
7
+ import type { AppShellRenderLink } from '../app/AppShell';
8
+
9
+ export interface PricingPlan {
10
+ name: string;
11
+ monthly: number;
12
+ annual: number;
13
+ description: string;
14
+ cta: string;
15
+ highlighted?: boolean;
16
+ features: PricingFeature[];
17
+ }
18
+
19
+ export interface PricingSectionProps {
20
+ title?: string;
21
+ description?: string;
22
+ badge?: string;
23
+ plans: PricingPlan[];
24
+ saveBadge?: string;
25
+ isAuthenticated?: boolean;
26
+ renderLink?: AppShellRenderLink;
27
+ footnote?: string;
28
+ }
29
+
30
+ const defaultRenderLink: AppShellRenderLink = ({ href, children, className }) => (
31
+ <a href={href} className={className}>{children}</a>
32
+ );
33
+
34
+ export function PricingSection({
35
+ title = 'Simple, transparent pricing',
36
+ description = 'Start for free. Scale as you grow. No hidden fees.',
37
+ badge = 'Pricing',
38
+ plans,
39
+ saveBadge = 'Save 34%',
40
+ isAuthenticated,
41
+ renderLink = defaultRenderLink,
42
+ footnote,
43
+ }: PricingSectionProps) {
44
+ const [annual, setAnnual] = React.useState(false);
45
+
46
+ return (
47
+ <section className="overflow-hidden rounded-2xl border bg-card">
48
+ <div className="px-6 py-14 sm:px-10 sm:py-20">
49
+ <div className="mx-auto max-w-5xl">
50
+ {/* Header */}
51
+ <div className="text-center">
52
+ {badge && (
53
+ <Badge variant="secondary" className="mb-4">{badge}</Badge>
54
+ )}
55
+ <h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
56
+ {title}
57
+ </h2>
58
+ {description && (
59
+ <p className="mx-auto mt-4 max-w-xl text-pretty text-muted-foreground">
60
+ {description}
61
+ </p>
62
+ )}
63
+
64
+ {/* Billing toggle */}
65
+ <div className="mt-6 inline-flex items-center gap-3 rounded-full border bg-muted p-1">
66
+ <button
67
+ onClick={() => setAnnual(false)}
68
+ className={cn(
69
+ 'rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
70
+ !annual ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
71
+ )}
72
+ >
73
+ Monthly
74
+ </button>
75
+ <button
76
+ onClick={() => setAnnual(true)}
77
+ className={cn(
78
+ 'flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
79
+ annual ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
80
+ )}
81
+ >
82
+ Annual
83
+ {saveBadge && (
84
+ <Badge variant="secondary" className="text-xs">{saveBadge}</Badge>
85
+ )}
86
+ </button>
87
+ </div>
88
+ </div>
89
+
90
+ {/* Plan cards */}
91
+ <StaggerChildren className="mt-10 grid gap-4 lg:grid-cols-3">
92
+ {plans.map((plan) => {
93
+ const price = annual ? plan.annual : plan.monthly;
94
+ return (
95
+ <PricingCard
96
+ key={plan.name}
97
+ name={plan.name}
98
+ price={`$${price}`}
99
+ period={price > 0 ? '/mo' : ''}
100
+ description={plan.description}
101
+ features={plan.features}
102
+ cta={plan.cta}
103
+ highlighted={plan.highlighted}
104
+ badge={plan.highlighted ? 'Most popular' : undefined}
105
+ />
106
+ );
107
+ })}
108
+ </StaggerChildren>
109
+
110
+ {/* Footnote */}
111
+ {footnote && (
112
+ <p className="mt-8 text-center text-sm text-muted-foreground">
113
+ {footnote}
114
+ </p>
115
+ )}
116
+ </div>
117
+ </div>
118
+ </section>
119
+ );
120
+ }
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Badge, cn } from '@olwiba/cn';
5
+ import { FadeIn } from '../motion/FadeIn';
6
+
7
+ export interface SectionTitleProps {
8
+ title: string;
9
+ description?: string;
10
+ badge?: string;
11
+ className?: string;
12
+ }
13
+
14
+ export function SectionTitle({ title, description, badge, className }: SectionTitleProps) {
15
+ return (
16
+ <FadeIn direction="up">
17
+ <div className={cn('text-center', className)}>
18
+ {badge && <Badge variant="secondary" className="mb-4">{badge}</Badge>}
19
+ <h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
20
+ {title}
21
+ </h2>
22
+ {description && (
23
+ <p className="mx-auto mt-4 max-w-2xl text-pretty text-muted-foreground">
24
+ {description}
25
+ </p>
26
+ )}
27
+ </div>
28
+ </FadeIn>
29
+ );
30
+ }
@@ -0,0 +1,63 @@
1
+ 'use client';
2
+
3
+ import { Separator } from '@olwiba/cn';
4
+ import { SectionTitle } from './SectionTitle';
5
+ import { StaggerChildren } from '../motion/StaggerChildren';
6
+ import { CountUp } from '../motion/CountUp';
7
+
8
+ export interface StatsSectionProps {
9
+ title?: string;
10
+ description?: string;
11
+ badge?: string;
12
+ stats: Array<{ value: string; label: string; description?: string }>;
13
+ }
14
+
15
+ function parseStatValue(value: string) {
16
+ const match = value.match(/^(\d+(?:\.\d+)?)(.*)/);
17
+ if (!match) return null;
18
+ const num = parseFloat(match[1]);
19
+ const suffix = match[2] || '';
20
+ const decimals = match[1].includes('.') ? match[1].split('.')[1].length : 0;
21
+ return { num, suffix, decimals };
22
+ }
23
+
24
+ function StatValue({ value }: { value: string }) {
25
+ const parsed = parseStatValue(value);
26
+ if (!parsed) return <>{value}</>;
27
+ return <CountUp to={parsed.num} decimals={parsed.decimals} suffix={parsed.suffix} />;
28
+ }
29
+
30
+ export function StatsSection({
31
+ title = 'Built for scale, used in production',
32
+ description,
33
+ badge = 'By the numbers',
34
+ stats,
35
+ }: StatsSectionProps) {
36
+ return (
37
+ <section className="overflow-hidden rounded-2xl border bg-card">
38
+ <div className="relative px-6 py-14 sm:px-10 sm:py-20">
39
+ <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom,hsl(var(--primary)/0.08),transparent_60%)]" />
40
+ <div className="relative mx-auto max-w-4xl">
41
+ <SectionTitle title={title} description={description} badge={badge} />
42
+
43
+ <StaggerChildren className="mt-12 grid gap-px overflow-hidden rounded-2xl border bg-border sm:grid-cols-2 lg:grid-cols-4">
44
+ {stats.map((stat) => (
45
+ <div key={stat.label} className="flex flex-col gap-2 bg-card px-6 py-8">
46
+ <div className="text-4xl font-bold tracking-tight">
47
+ <StatValue value={stat.value} />
48
+ </div>
49
+ <div className="font-medium">{stat.label}</div>
50
+ {stat.description && (
51
+ <>
52
+ <Separator />
53
+ <p className="text-sm text-muted-foreground">{stat.description}</p>
54
+ </>
55
+ )}
56
+ </div>
57
+ ))}
58
+ </StaggerChildren>
59
+ </div>
60
+ </div>
61
+ </section>
62
+ );
63
+ }
@@ -0,0 +1,110 @@
1
+ 'use client';
2
+
3
+ import { Github, Linkedin, Twitter } from 'lucide-react';
4
+ import { Avatar, AvatarFallback, AvatarImage, Badge } from '@olwiba/cn';
5
+
6
+ const team = [
7
+ {
8
+ name: 'Olivia Reed',
9
+ role: 'Co-Founder & CEO',
10
+ bio: 'Previously led product at two YC companies. Obsessed with design systems and developer experience.',
11
+ avatar: 'https://ui.shadcn.com/avatars/01.png',
12
+ initials: 'OR',
13
+ social: { twitter: '#', github: '#', linkedin: '#' },
14
+ },
15
+ {
16
+ name: 'Marcus Webb',
17
+ role: 'Co-Founder & CTO',
18
+ bio: 'Full-stack engineer with 12 years building UI infrastructure. Open source maintainer.',
19
+ avatar: 'https://ui.shadcn.com/avatars/02.png',
20
+ initials: 'MW',
21
+ social: { twitter: '#', github: '#', linkedin: '#' },
22
+ },
23
+ {
24
+ name: 'Priya Nair',
25
+ role: 'Head of Design',
26
+ bio: 'Interaction designer turned design systems engineer. Bridges the gap between Figma and production.',
27
+ avatar: 'https://ui.shadcn.com/avatars/03.png',
28
+ initials: 'PN',
29
+ social: { twitter: '#', github: null, linkedin: '#' },
30
+ },
31
+ {
32
+ name: 'Daniel Frost',
33
+ role: 'Senior Engineer',
34
+ bio: 'Accessibility and performance specialist. If it doesn\'t work with a keyboard, it\'s broken.',
35
+ avatar: 'https://ui.shadcn.com/avatars/04.png',
36
+ initials: 'DF',
37
+ social: { twitter: null, github: '#', linkedin: '#' },
38
+ },
39
+ {
40
+ name: 'Aisha Okafor',
41
+ role: 'Developer Relations',
42
+ bio: 'Writes docs, builds examples, and makes sure every engineer can ship their first block in under an hour.',
43
+ avatar: 'https://ui.shadcn.com/avatars/05.png',
44
+ initials: 'AO',
45
+ social: { twitter: '#', github: '#', linkedin: '#' },
46
+ },
47
+ {
48
+ name: 'Tom Halvorsen',
49
+ role: 'Growth',
50
+ bio: 'Turns happy users into loud advocates. Runs the community and keeps the changelog sharp.',
51
+ avatar: 'https://ui.shadcn.com/avatars/06.png',
52
+ initials: 'TH',
53
+ social: { twitter: '#', github: null, linkedin: '#' },
54
+ },
55
+ ];
56
+
57
+ export function TeamSection() {
58
+ return (
59
+ <section className="overflow-hidden rounded-2xl border bg-card">
60
+ <div className="px-6 py-14 sm:px-10 sm:py-20">
61
+ <div className="mx-auto max-w-5xl">
62
+ <div className="text-center">
63
+ <Badge variant="secondary" className="mb-4">Team</Badge>
64
+ <h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
65
+ The people behind Olwiba
66
+ </h2>
67
+ <p className="mx-auto mt-4 max-w-xl text-pretty text-muted-foreground">
68
+ A small team with deep experience in design systems, open source, and developer tooling.
69
+ </p>
70
+ </div>
71
+
72
+ <div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
73
+ {team.map((member) => (
74
+ <div key={member.name} className="rounded-2xl border bg-muted/40 p-5">
75
+ <div className="flex items-start gap-4">
76
+ <Avatar className="size-12 rounded-xl">
77
+ <AvatarImage src={member.avatar} alt={member.name} />
78
+ <AvatarFallback className="rounded-xl">{member.initials}</AvatarFallback>
79
+ </Avatar>
80
+ <div className="min-w-0">
81
+ <div className="font-semibold leading-tight">{member.name}</div>
82
+ <div className="text-sm text-muted-foreground">{member.role}</div>
83
+ </div>
84
+ </div>
85
+ <p className="mt-3 text-sm leading-relaxed text-muted-foreground">{member.bio}</p>
86
+ <div className="mt-4 flex items-center gap-2">
87
+ {member.social.twitter && (
88
+ <a href={member.social.twitter} aria-label="Twitter" className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
89
+ <Twitter className="size-3.5" />
90
+ </a>
91
+ )}
92
+ {member.social.github && (
93
+ <a href={member.social.github} aria-label="GitHub" className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
94
+ <Github className="size-3.5" />
95
+ </a>
96
+ )}
97
+ {member.social.linkedin && (
98
+ <a href={member.social.linkedin} aria-label="LinkedIn" className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
99
+ <Linkedin className="size-3.5" />
100
+ </a>
101
+ )}
102
+ </div>
103
+ </div>
104
+ ))}
105
+ </div>
106
+ </div>
107
+ </div>
108
+ </section>
109
+ );
110
+ }
@@ -0,0 +1,56 @@
1
+ 'use client';
2
+
3
+ import { TestimonialCard } from '../components/TestimonialCard';
4
+ import { SectionTitle } from './SectionTitle';
5
+ import { StaggerChildren } from '../motion/StaggerChildren';
6
+
7
+ export interface TestimonialsSectionProps {
8
+ title?: string;
9
+ description?: string;
10
+ badge?: string;
11
+ testimonials: Array<{
12
+ quote: string;
13
+ name: string;
14
+ role: string;
15
+ company?: string;
16
+ avatar?: string;
17
+ initials?: string;
18
+ rating?: number;
19
+ }>;
20
+ }
21
+
22
+ export function TestimonialsSection({
23
+ title = 'Trusted by teams shipping real products',
24
+ description = "Here's what engineers and product teams say after using Olwiba in production.",
25
+ badge = 'Testimonials',
26
+ testimonials,
27
+ }: TestimonialsSectionProps) {
28
+ return (
29
+ <section className="overflow-hidden rounded-2xl border bg-card">
30
+ <div className="px-6 py-14 sm:px-10 sm:py-20">
31
+ <div className="mx-auto max-w-5xl">
32
+ <SectionTitle title={title} description={description} badge={badge} />
33
+
34
+ <StaggerChildren className="mt-12 columns-1 gap-4 sm:columns-2 lg:columns-3">
35
+ {testimonials.map((t) => (
36
+ <div
37
+ key={t.name}
38
+ className="mb-4 break-inside-avoid transition-transform hover:-translate-y-0.5"
39
+ >
40
+ <TestimonialCard
41
+ quote={t.quote}
42
+ name={t.name}
43
+ role={t.role}
44
+ company={t.company}
45
+ avatar={t.avatar}
46
+ initials={t.initials}
47
+ rating={t.rating}
48
+ />
49
+ </div>
50
+ ))}
51
+ </StaggerChildren>
52
+ </div>
53
+ </div>
54
+ </section>
55
+ );
56
+ }
@@ -0,0 +1,64 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '@olwiba/cn';
5
+
6
+ export interface CountUpProps extends React.HTMLAttributes<HTMLSpanElement> {
7
+ from?: number;
8
+ to: number;
9
+ duration?: number;
10
+ decimals?: number;
11
+ prefix?: string;
12
+ suffix?: string;
13
+ once?: boolean;
14
+ }
15
+
16
+ export function CountUp({
17
+ from = 0,
18
+ to,
19
+ duration = 1500,
20
+ decimals = 0,
21
+ prefix = '',
22
+ suffix = '',
23
+ once = true,
24
+ className,
25
+ ...props
26
+ }: CountUpProps) {
27
+ const ref = React.useRef<HTMLSpanElement>(null);
28
+ const [value, setValue] = React.useState(from);
29
+ const hasStarted = React.useRef(false);
30
+
31
+ React.useEffect(() => {
32
+ const el = ref.current;
33
+ if (!el) return;
34
+
35
+ const observer = new IntersectionObserver(
36
+ ([entry]) => {
37
+ if (entry.isIntersecting && (!once || !hasStarted.current)) {
38
+ hasStarted.current = true;
39
+ const start = performance.now();
40
+
41
+ const tick = (now: number) => {
42
+ const elapsed = now - start;
43
+ const progress = Math.min(elapsed / duration, 1);
44
+ const eased = 1 - Math.pow(1 - progress, 3);
45
+ setValue(from + (to - from) * eased);
46
+ if (progress < 1) requestAnimationFrame(tick);
47
+ };
48
+
49
+ requestAnimationFrame(tick);
50
+ }
51
+ },
52
+ { threshold: 0.5 },
53
+ );
54
+
55
+ observer.observe(el);
56
+ return () => observer.disconnect();
57
+ }, [from, to, duration, once]);
58
+
59
+ return (
60
+ <span ref={ref} className={cn(className)} {...props}>
61
+ {prefix}{value.toFixed(decimals)}{suffix}
62
+ </span>
63
+ );
64
+ }
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '@olwiba/cn';
5
+
6
+ export interface FadeInProps extends React.HTMLAttributes<HTMLDivElement> {
7
+ delay?: number;
8
+ duration?: number;
9
+ direction?: 'up' | 'down' | 'left' | 'right' | 'none';
10
+ once?: boolean;
11
+ children: React.ReactNode;
12
+ }
13
+
14
+ const translateMap = {
15
+ up: 'translate-y-6',
16
+ down: '-translate-y-6',
17
+ left: 'translate-x-6',
18
+ right: '-translate-x-6',
19
+ none: '',
20
+ };
21
+
22
+ export function FadeIn({
23
+ delay = 0,
24
+ duration = 600,
25
+ direction = 'up',
26
+ once = true,
27
+ children,
28
+ className,
29
+ style,
30
+ ...props
31
+ }: FadeInProps) {
32
+ const ref = React.useRef<HTMLDivElement>(null);
33
+ const [visible, setVisible] = React.useState(false);
34
+
35
+ React.useEffect(() => {
36
+ const el = ref.current;
37
+ if (!el) return;
38
+
39
+ const observer = new IntersectionObserver(
40
+ ([entry]) => {
41
+ if (entry.isIntersecting) {
42
+ setVisible(true);
43
+ if (once) observer.disconnect();
44
+ } else if (!once) {
45
+ setVisible(false);
46
+ }
47
+ },
48
+ { threshold: 0.1 },
49
+ );
50
+
51
+ observer.observe(el);
52
+ return () => observer.disconnect();
53
+ }, [once]);
54
+
55
+ return (
56
+ <div
57
+ ref={ref}
58
+ className={cn(
59
+ 'transition-all',
60
+ visible ? 'opacity-100 translate-x-0 translate-y-0' : `opacity-0 ${translateMap[direction]}`,
61
+ className,
62
+ )}
63
+ style={{ transitionDuration: `${duration}ms`, transitionDelay: `${delay}ms`, ...style }}
64
+ {...props}
65
+ >
66
+ {children}
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '@olwiba/cn';
5
+
6
+ export interface PageTransitionProps extends React.HTMLAttributes<HTMLDivElement> {
7
+ variant?: 'fade' | 'slide-up' | 'slide-down';
8
+ duration?: number;
9
+ children: React.ReactNode;
10
+ }
11
+
12
+ export function PageTransition({
13
+ variant = 'fade',
14
+ duration = 300,
15
+ children,
16
+ className,
17
+ style,
18
+ ...props
19
+ }: PageTransitionProps) {
20
+ const [mounted, setMounted] = React.useState(false);
21
+
22
+ React.useEffect(() => {
23
+ const id = requestAnimationFrame(() => setMounted(true));
24
+ return () => cancelAnimationFrame(id);
25
+ }, []);
26
+
27
+ const initial: React.CSSProperties =
28
+ variant === 'slide-up'
29
+ ? { opacity: 0, transform: 'translateY(16px)' }
30
+ : variant === 'slide-down'
31
+ ? { opacity: 0, transform: 'translateY(-16px)' }
32
+ : { opacity: 0 };
33
+
34
+ const entered: React.CSSProperties = { opacity: 1, transform: 'translateY(0)' };
35
+
36
+ return (
37
+ <div
38
+ className={cn(className)}
39
+ style={{
40
+ transition: `opacity ${duration}ms ease, transform ${duration}ms ease`,
41
+ ...(mounted ? entered : initial),
42
+ ...style,
43
+ }}
44
+ {...props}
45
+ >
46
+ {children}
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,74 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '@olwiba/cn';
5
+
6
+ export interface StaggerChildrenProps extends React.HTMLAttributes<HTMLDivElement> {
7
+ stagger?: number;
8
+ delay?: number;
9
+ duration?: number;
10
+ direction?: 'up' | 'down' | 'left' | 'right' | 'none';
11
+ once?: boolean;
12
+ children: React.ReactNode;
13
+ }
14
+
15
+ const translateMap = {
16
+ up: [0, 20],
17
+ down: [0, -20],
18
+ left: [20, 0],
19
+ right: [-20, 0],
20
+ none: [0, 0],
21
+ };
22
+
23
+ export function StaggerChildren({
24
+ stagger = 80,
25
+ delay = 0,
26
+ duration = 500,
27
+ direction = 'up',
28
+ once = true,
29
+ children,
30
+ className,
31
+ ...props
32
+ }: StaggerChildrenProps) {
33
+ const ref = React.useRef<HTMLDivElement>(null);
34
+ const [visible, setVisible] = React.useState(false);
35
+
36
+ React.useEffect(() => {
37
+ const el = ref.current;
38
+ if (!el) return;
39
+
40
+ const observer = new IntersectionObserver(
41
+ ([entry]) => {
42
+ if (entry.isIntersecting) {
43
+ setVisible(true);
44
+ if (once) observer.disconnect();
45
+ } else if (!once) {
46
+ setVisible(false);
47
+ }
48
+ },
49
+ { threshold: 0.1 },
50
+ );
51
+
52
+ observer.observe(el);
53
+ return () => observer.disconnect();
54
+ }, [once]);
55
+
56
+ const [tx, ty] = translateMap[direction];
57
+
58
+ return (
59
+ <div ref={ref} className={cn(className)} {...props}>
60
+ {React.Children.map(children, (child, i) => (
61
+ <div
62
+ style={{
63
+ transition: `opacity ${duration}ms ease, transform ${duration}ms ease`,
64
+ transitionDelay: `${delay + i * stagger}ms`,
65
+ opacity: visible ? 1 : 0,
66
+ transform: visible ? 'translate(0,0)' : `translate(${tx}px,${ty}px)`,
67
+ }}
68
+ >
69
+ {child}
70
+ </div>
71
+ ))}
72
+ </div>
73
+ );
74
+ }