@olwiba/ui 0.1.5 → 0.1.8

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.
@@ -17,7 +17,8 @@ export interface PricingCardProps extends React.HTMLAttributes<HTMLDivElement> {
17
17
  features: PricingFeature[];
18
18
  cta: string;
19
19
  highlighted?: boolean;
20
- badge?: string;
20
+ disabled?: boolean;
21
+ badge?: React.ReactNode;
21
22
  onSelect?: () => void;
22
23
  }
23
24
 
@@ -29,6 +30,7 @@ export function PricingCard({
29
30
  features,
30
31
  cta,
31
32
  highlighted = false,
33
+ disabled = false,
32
34
  badge,
33
35
  onSelect,
34
36
  className,
@@ -39,20 +41,21 @@ export function PricingCard({
39
41
  const cardInner = (
40
42
  <div
41
43
  className={cn(
42
- 'relative flex flex-col border p-6',
44
+ 'relative flex flex-col border p-6 transition-opacity',
43
45
  mode === 'playful'
44
46
  ? 'rounded-2xl rotate-[0.3deg]'
45
47
  : mode === 'smooth'
46
48
  ? 'rounded-3xl'
47
49
  : 'rounded-2xl',
48
50
  highlighted ? 'border-primary bg-primary/5 shadow-sm' : 'bg-card',
51
+ disabled && 'pointer-events-none opacity-40 select-none',
49
52
  mode !== 'playful' && className,
50
53
  )}
51
54
  {...(mode !== 'playful' ? props : {})}
52
55
  >
53
56
  {badge && (
54
57
  <div className="absolute -top-3 left-1/2 -translate-x-1/2">
55
- <Badge>{badge}</Badge>
58
+ {typeof badge === 'string' ? <Badge>{badge}</Badge> : badge}
56
59
  </div>
57
60
  )}
58
61
 
@@ -69,6 +72,7 @@ export function PricingCard({
69
72
  variant={highlighted ? 'default' : 'outline'}
70
73
  className="mt-6 w-full"
71
74
  onClick={onSelect}
75
+ disabled={disabled}
72
76
  >
73
77
  {cta}
74
78
  </Button>
@@ -4,14 +4,14 @@ import * as React from 'react';
4
4
  import { Moon, Sun } from 'lucide-react';
5
5
  import { Button } from '@olwiba/cn';
6
6
 
7
- export function ThemeSwitchMinimal() {
8
- const [theme, setThemeState] = React.useState<'light' | 'dark'>('dark');
7
+ function getInitialTheme(): 'light' | 'dark' {
8
+ if (typeof document === 'undefined') return 'dark';
9
+ const stored = localStorage.getItem('theme') as 'light' | 'dark' | null;
10
+ return stored ?? (document.documentElement.classList.contains('dark') ? 'dark' : 'light');
11
+ }
9
12
 
10
- React.useEffect(() => {
11
- const stored = localStorage.getItem('theme') as 'light' | 'dark' | null;
12
- const resolved = stored ?? (document.documentElement.classList.contains('dark') ? 'dark' : 'light');
13
- setThemeState(resolved);
14
- }, []);
13
+ export function ThemeSwitchMinimal() {
14
+ const [theme, setThemeState] = React.useState<'light' | 'dark'>(getInitialTheme);
15
15
 
16
16
  const toggle = () => {
17
17
  const next = theme === 'dark' ? 'light' : 'dark';
package/src/index.ts CHANGED
@@ -54,15 +54,22 @@ export {
54
54
  export { SectionTitle, type SectionTitleProps } from './marketing/SectionTitle';
55
55
  export { HeroSection, type HeroSectionProps } from './marketing/HeroSection';
56
56
  export { FeaturesSection, type FeaturesSectionProps } from './marketing/FeaturesSection';
57
+ export { GroupedFeaturesSection, type GroupedFeaturesSectionProps, type GroupedFeatureGroup } from './marketing/GroupedFeaturesSection';
58
+ export { StepsSection, type StepsSectionProps, type StepItem } from './marketing/StepsSection';
59
+ export { TechStackSection, type TechStackSectionProps, type TechStackItem } from './marketing/TechStackSection';
57
60
  export { CarouselSection, type CarouselSectionProps } from './marketing/CarouselSection';
61
+ export { FeatureMarqueeSection, type FeatureMarqueeSectionProps, type FeatureMarqueeRow, type FeatureMarqueeItem } from './marketing/FeatureMarqueeSection';
58
62
  export { CtaSection, type CtaSectionProps } from './marketing/CtaSection';
63
+ export { CtaCardSection, type CtaCardSectionProps } from './marketing/CtaCardSection';
59
64
  export { PricingSection, type PricingSectionProps, type PricingPlan } from './marketing/PricingSection';
60
65
  export { TestimonialsSection, type TestimonialsSectionProps } from './marketing/TestimonialsSection';
61
- export { TeamSection } from './marketing/TeamSection';
66
+ export { TeamSection, type TeamMember, type TeamSectionProps } from './marketing/TeamSection';
62
67
  export { FaqSection, type FaqSectionProps } from './marketing/FaqSection';
63
68
  export { StatsSection, type StatsSectionProps } from './marketing/StatsSection';
64
69
  export { NewsletterSection, type NewsletterSectionProps } from './marketing/NewsletterSection';
65
70
  export { ContactSection, type ContactSectionProps, type ContactInfoItem } from './marketing/ContactSection';
71
+ export { ComparisonSection, type ComparisonSectionProps, type ComparisonColumn } from './marketing/ComparisonSection';
72
+ export { QualificationSection, type QualificationSectionProps, type QualificationColumn } from './marketing/QualificationSection';
66
73
 
67
74
  // ─── Marketing — elements ─────────────────────────────────────────────────────
68
75
  export { Navbar, type NavbarProps } from './marketing/Navbar';
@@ -77,6 +84,8 @@ export { Overlay, type OverlayProps, type OverlayVariant } from './overlays/Over
77
84
  export { FadeIn, type FadeInProps } from './motion/FadeIn';
78
85
  export { StaggerChildren, type StaggerChildrenProps } from './motion/StaggerChildren';
79
86
  export { CountUp, type CountUpProps } from './motion/CountUp';
87
+ export { CountdownTimer, type CountdownTimerProps } from './motion/CountdownTimer';
88
+ export { AnimatedPill, type AnimatedPillProps } from './motion/AnimatedPill';
80
89
  export { PageTransition, type PageTransitionProps } from './motion/PageTransition';
81
90
 
82
91
  // ─── Components — interactive ────────────────────────────────────────────────
@@ -97,12 +106,17 @@ export { TestimonialCard, type TestimonialCardProps } from './components/Testimo
97
106
  export { PricingCard, type PricingCardProps, type PricingFeature } from './components/PricingCard';
98
107
  export { ImageCard, type ImageCardProps } from './components/ImageCard';
99
108
 
109
+ // ─── Components — diagram ────────────────────────────────────────────────────
110
+ export { FlowConnector, type FlowConnectorProps } from './components/FlowConnector';
111
+ export { FlowBracket, type FlowBracketProps } from './components/FlowBracket';
112
+
100
113
  // ─── Components — utility ────────────────────────────────────────────────────
101
114
  export { FullPageSpinner } from './components/Spinner';
102
115
  export { PageHeader, type PageHeaderProps, type PageHeaderBreadcrumb, type PageHeaderBackButton } from './components/PageHeader';
103
116
  export { Suspensed } from './components/Suspensed';
104
117
  export { ThemeSwitchMinimal } from './components/ThemeSwitchMinimal';
105
118
  export { ModeSwitchMinimal } from './components/ModeSwitchMinimal';
119
+ export { BrandColorSwitchMinimal } from './components/BrandColorSwitchMinimal';
106
120
  export { ThemeColorUpdater } from './components/ThemeColorUpdater';
107
121
  export { VersionBanner } from './components/VersionBanner';
108
122
  export { DevBanner, type DevBannerProps } from './components/DevBanner';
@@ -113,7 +127,7 @@ export { RootErrorFallback } from './components/RootErrorFallback';
113
127
  export { PostCard, type PostCardProps } from './blog/PostCard';
114
128
  export { PostList, type PostListProps } from './blog/PostList';
115
129
  export { ChangelogList, type ChangelogListProps } from './blog/ChangelogList';
116
- export { ChangelogCard, type ChangelogCardProps } from './blog/ChangelogCard';
130
+ export { ChangelogCard, type ChangelogCardProps, type ChangelogHighlight, type ChangelogReleaseType } from './blog/ChangelogCard';
117
131
 
118
132
  // ─── MDX ──────────────────────────────────────────────────────────────────────
119
133
  export { MdxContent, type MdxContentProps } from './blog/MdxContent';
@@ -0,0 +1,109 @@
1
+ 'use client';
2
+
3
+ import { Check, Minus } from 'lucide-react';
4
+ import { cn, useUIVariant } from '@olwiba/cn';
5
+ import { SectionTitle } from './SectionTitle';
6
+ import { StaggerChildren } from '../motion/StaggerChildren';
7
+
8
+ export interface ComparisonColumn {
9
+ label: string;
10
+ cost: string;
11
+ costDetail?: string;
12
+ items: string[];
13
+ highlighted?: boolean;
14
+ }
15
+
16
+ export interface ComparisonSectionProps {
17
+ badge?: string;
18
+ title?: string;
19
+ description?: string;
20
+ columns: ComparisonColumn[];
21
+ }
22
+
23
+ export function ComparisonSection({
24
+ badge = 'The math',
25
+ title = 'The math is simple',
26
+ description,
27
+ columns,
28
+ }: ComparisonSectionProps) {
29
+ const mode = useUIVariant();
30
+ const sectionClasses = cn(
31
+ 'overflow-hidden bg-card',
32
+ mode === 'smooth' && 'rounded-3xl border',
33
+ mode === 'playful' && 'rounded-2xl border-primary/25 border',
34
+ !mode && 'rounded-2xl border',
35
+ );
36
+
37
+ return (
38
+ <section className={sectionClasses}>
39
+ <div className="px-6 py-14 sm:px-10 sm:py-20">
40
+ <div className="mx-auto max-w-4xl">
41
+ <SectionTitle badge={badge} title={title} description={description} />
42
+
43
+ <StaggerChildren className="mt-12 grid gap-4 sm:grid-cols-3">
44
+ {columns.map((col) => (
45
+ <div
46
+ key={col.label}
47
+ className={cn(
48
+ 'flex flex-col rounded-2xl border p-6',
49
+ col.highlighted
50
+ ? 'border-foreground bg-foreground text-background'
51
+ : 'border-border bg-muted/40',
52
+ )}
53
+ >
54
+ <div className="mb-6">
55
+ <p
56
+ className={cn(
57
+ 'text-xs font-semibold uppercase tracking-widest',
58
+ col.highlighted ? 'text-background/60' : 'text-muted-foreground',
59
+ )}
60
+ >
61
+ {col.label}
62
+ </p>
63
+ <p
64
+ className={cn(
65
+ 'mt-2 text-2xl font-bold tracking-tight',
66
+ col.highlighted ? 'text-background' : 'text-foreground',
67
+ )}
68
+ >
69
+ {col.cost}
70
+ </p>
71
+ {col.costDetail && (
72
+ <p
73
+ className={cn(
74
+ 'mt-1 text-sm',
75
+ col.highlighted ? 'text-background/60' : 'text-muted-foreground',
76
+ )}
77
+ >
78
+ {col.costDetail}
79
+ </p>
80
+ )}
81
+ </div>
82
+
83
+ <ul className="flex flex-col gap-3">
84
+ {col.items.map((item) => (
85
+ <li key={item} className="flex items-start gap-2.5">
86
+ {col.highlighted ? (
87
+ <Check className="mt-0.5 size-4 shrink-0 text-background/70" />
88
+ ) : (
89
+ <Minus className="mt-0.5 size-4 shrink-0 text-muted-foreground/50" />
90
+ )}
91
+ <span
92
+ className={cn(
93
+ 'text-sm leading-snug',
94
+ col.highlighted ? 'text-background/90' : 'text-muted-foreground',
95
+ )}
96
+ >
97
+ {item}
98
+ </span>
99
+ </li>
100
+ ))}
101
+ </ul>
102
+ </div>
103
+ ))}
104
+ </StaggerChildren>
105
+ </div>
106
+ </div>
107
+ </section>
108
+ );
109
+ }
@@ -1,8 +1,8 @@
1
1
  'use client';
2
2
 
3
3
  import * as React from 'react';
4
- import { Mail, MapPin, MessageSquare, Phone, Send, type LucideIcon } from 'lucide-react';
5
- import { Badge, Button, Input, Label, Separator, Textarea } from '@olwiba/cn';
4
+ import { Mail, MapPin, MessageSquare, Send, type LucideIcon } from 'lucide-react';
5
+ import { Badge, Button, Checkbox, Input, Label, Separator, Textarea } from '@olwiba/cn';
6
6
 
7
7
  export type ContactInfoItem = {
8
8
  label: string;
@@ -15,6 +15,7 @@ export interface ContactSectionProps {
15
15
  title?: string;
16
16
  description?: string;
17
17
  contactInfo?: ContactInfoItem[];
18
+ privacyHref?: string;
18
19
  successTitle?: string;
19
20
  successDescription?: string;
20
21
  sendAnotherLabel?: string;
@@ -23,17 +24,16 @@ export interface ContactSectionProps {
23
24
 
24
25
  const defaultContactInfoResolved: ContactInfoItem[] = [
25
26
  { icon: Mail, label: 'Email', value: 'hello@olwiba.com' },
26
- { icon: Phone, label: 'Phone', value: '+1 (555) 000-0000' },
27
- { icon: MapPin, label: 'Office', value: 'San Francisco, CA' },
27
+ { icon: MapPin, label: 'Base', value: 'Aotearoa New Zealand' },
28
28
  ];
29
29
 
30
30
  const defaults = {
31
- badge: 'Contact us',
31
+ badge: 'Contact',
32
32
  title: "Let's talk",
33
33
  description:
34
- "Have a question or want to work together? Fill in the form and we'll get back to you within one business day.",
34
+ "Have a question or want to work together? Fill in the form and you'll hear back from the person who built it.",
35
35
  successTitle: 'Message sent',
36
- successDescription: "Thanks for reaching out. We'll get back to you within one business day.",
36
+ successDescription: "Thanks for reaching out. You'll get a reply within one business day.",
37
37
  sendAnotherLabel: 'Send another',
38
38
  };
39
39
 
@@ -42,6 +42,7 @@ export function ContactSection({
42
42
  title = defaults.title,
43
43
  description = defaults.description,
44
44
  contactInfo = defaultContactInfoResolved,
45
+ privacyHref,
45
46
  successTitle = defaults.successTitle,
46
47
  successDescription = defaults.successDescription,
47
48
  sendAnotherLabel = defaults.sendAnotherLabel,
@@ -58,62 +59,67 @@ export function ContactSection({
58
59
  return (
59
60
  <section className="overflow-hidden rounded-2xl border bg-card">
60
61
  <div className="grid lg:grid-cols-[1fr_1.4fr]">
62
+
63
+ {/* Left panel */}
61
64
  <div className="relative overflow-hidden bg-primary px-8 py-12 text-primary-foreground">
62
- <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary-foreground)/0.1),transparent_60%)]" />
63
- <div className="relative space-y-6">
65
+ <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary-foreground)/0.12),transparent_55%)]" />
66
+ <div className="absolute inset-0 bg-[radial-gradient(circle_at_bottom_left,hsl(var(--primary-foreground)/0.06),transparent_60%)]" />
67
+ <div className="relative flex h-full flex-col gap-8">
64
68
  <div>
65
69
  <Badge
66
70
  variant="secondary"
67
- className="mb-3 bg-primary-foreground/15 text-primary-foreground hover:bg-primary-foreground/20"
71
+ className="mb-4 bg-primary-foreground/15 text-primary-foreground hover:bg-primary-foreground/20"
68
72
  >
69
73
  {badge}
70
74
  </Badge>
71
- <h2 className="text-2xl font-semibold">{title}</h2>
72
- <p className="mt-2 text-sm text-primary-foreground/70">{description}</p>
75
+ <h2 className="text-3xl font-semibold leading-tight">{title}</h2>
76
+ <p className="mt-3 text-sm leading-relaxed text-primary-foreground/70">{description}</p>
73
77
  </div>
74
78
 
75
79
  <Separator className="bg-primary-foreground/20" />
76
80
 
77
- <div className="space-y-4">
81
+ <div className="space-y-5">
78
82
  {contactInfo.map(({ icon: Icon = Mail, label, value }) => (
79
- <div key={label} className="flex items-center gap-3">
80
- <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-foreground/10">
83
+ <div key={label} className="flex items-start gap-3">
84
+ <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-foreground/10">
81
85
  <Icon className="size-4" />
82
86
  </div>
83
87
  <div>
84
- <div className="text-xs text-primary-foreground/60">{label}</div>
85
- <div className="text-sm font-medium">{value}</div>
88
+ <div className="text-xs font-medium uppercase tracking-wider text-primary-foreground/50">{label}</div>
89
+ <div className="mt-0.5 text-sm font-medium">{value}</div>
86
90
  </div>
87
91
  </div>
88
92
  ))}
89
93
  </div>
94
+
95
+ <div className="mt-auto">
96
+ <p className="text-xs text-primary-foreground/40">
97
+ Response within one business day.
98
+ </p>
99
+ </div>
90
100
  </div>
91
101
  </div>
92
102
 
93
- <div className="p-8">
103
+ {/* Right panel — form */}
104
+ <div className="p-8 sm:p-10">
94
105
  {submitted ? (
95
- <div className="flex h-full min-h-[320px] flex-col items-center justify-center gap-4 text-center">
96
- <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
106
+ <div className="flex h-full min-h-[360px] flex-col items-center justify-center gap-5 text-center">
107
+ <div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
97
108
  <MessageSquare className="size-6" />
98
109
  </div>
99
110
  <div>
100
- <h3 className="font-semibold">{successTitle}</h3>
101
- <p className="mt-1 text-sm text-muted-foreground">{successDescription}</p>
111
+ <h3 className="text-lg font-semibold">{successTitle}</h3>
112
+ <p className="mt-1.5 text-sm text-muted-foreground">{successDescription}</p>
102
113
  </div>
103
- <Button variant="outline" onClick={() => setSubmitted(false)}>
114
+ <Button variant="outline" size="sm" onClick={() => setSubmitted(false)}>
104
115
  {sendAnotherLabel}
105
116
  </Button>
106
117
  </div>
107
118
  ) : (
108
119
  <form onSubmit={handleSubmit} className="space-y-5">
109
- <input
110
- type="text"
111
- name="company"
112
- tabIndex={-1}
113
- autoComplete="off"
114
- aria-hidden="true"
115
- className="hidden"
116
- />
120
+ {/* honeypot */}
121
+ <input type="text" name="_hp" tabIndex={-1} autoComplete="off" aria-hidden="true" className="hidden" />
122
+
117
123
  <div className="grid gap-4 sm:grid-cols-2">
118
124
  <div className="space-y-1.5">
119
125
  <Label htmlFor="contact-first">First name</Label>
@@ -124,24 +130,58 @@ export function ContactSection({
124
130
  <Input id="contact-last" name="lastName" placeholder="Reed" required />
125
131
  </div>
126
132
  </div>
133
+
134
+ <div className="grid gap-4 sm:grid-cols-2">
135
+ <div className="space-y-1.5">
136
+ <Label htmlFor="contact-company" className="flex gap-1">
137
+ Company
138
+ <span className="text-muted-foreground">(optional)</span>
139
+ </Label>
140
+ <Input id="contact-company" name="company" placeholder="Acme Inc." />
141
+ </div>
142
+ <div className="space-y-1.5">
143
+ <Label htmlFor="contact-phone" className="flex gap-1">
144
+ Phone
145
+ <span className="text-muted-foreground">(optional)</span>
146
+ </Label>
147
+ <Input id="contact-phone" name="phone" type="tel" placeholder="+1 (555) 000-0000" />
148
+ </div>
149
+ </div>
150
+
127
151
  <div className="space-y-1.5">
128
- <Label htmlFor="contact-email">Email</Label>
152
+ <Label htmlFor="contact-email">Work email</Label>
129
153
  <Input id="contact-email" name="email" type="email" placeholder="olivia@company.com" required />
130
154
  </div>
155
+
131
156
  <div className="space-y-1.5">
132
157
  <Label htmlFor="contact-subject">Subject</Label>
133
158
  <Input id="contact-subject" name="subject" placeholder="How can we help?" required />
134
159
  </div>
160
+
135
161
  <div className="space-y-1.5">
136
162
  <Label htmlFor="contact-message">Message</Label>
137
163
  <Textarea
138
164
  id="contact-message"
139
165
  name="message"
140
166
  placeholder="Tell us what you're working on..."
141
- className="min-h-28 resize-none"
167
+ className="min-h-32 resize-none"
142
168
  required
143
169
  />
144
170
  </div>
171
+
172
+ {privacyHref && (
173
+ <div className="flex items-start gap-3">
174
+ <Checkbox id="contact-privacy" name="privacy" required className="mt-0.5" />
175
+ <Label htmlFor="contact-privacy" className="text-sm font-normal leading-snug text-muted-foreground">
176
+ By submitting this form you agree to our{' '}
177
+ <a href={privacyHref} className="font-medium text-foreground underline-offset-2 hover:underline">
178
+ privacy policy
179
+ </a>
180
+ .
181
+ </Label>
182
+ </div>
183
+ )}
184
+
145
185
  <Button type="submit" className="w-full">
146
186
  Send message
147
187
  <Send className="ml-2 size-4" />
@@ -149,6 +189,7 @@ export function ContactSection({
149
189
  </form>
150
190
  )}
151
191
  </div>
192
+
152
193
  </div>
153
194
  </section>
154
195
  );
@@ -0,0 +1,95 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { ArrowRight, Rocket } from 'lucide-react';
5
+ import { cn } from '@olwiba/cn';
6
+ import type { AppShellRenderLink } from '../app/AppShell';
7
+
8
+ export interface CtaCardSectionProps {
9
+ heading: string;
10
+ description?: string;
11
+ primaryCta: { label: string; href: string };
12
+ footnote?: string;
13
+ icon?: React.ReactNode;
14
+ renderLink?: AppShellRenderLink;
15
+ }
16
+
17
+ const defaultRenderLink: AppShellRenderLink = ({ href, children, className }) => (
18
+ <a href={href} className={className}>{children}</a>
19
+ );
20
+
21
+ export function CtaCardSection({
22
+ heading,
23
+ description,
24
+ primaryCta,
25
+ footnote,
26
+ icon,
27
+ renderLink = defaultRenderLink,
28
+ }: CtaCardSectionProps) {
29
+ const ref = React.useRef<HTMLElement>(null);
30
+ const [visible, setVisible] = React.useState(false);
31
+
32
+ React.useEffect(() => {
33
+ const el = ref.current;
34
+ if (!el) return;
35
+ const observer = new IntersectionObserver(
36
+ ([entry]) => {
37
+ if (entry.isIntersecting) {
38
+ setVisible(true);
39
+ observer.disconnect();
40
+ }
41
+ },
42
+ { threshold: 0.15 },
43
+ );
44
+ observer.observe(el);
45
+ return () => observer.disconnect();
46
+ }, []);
47
+
48
+ return (
49
+ <section
50
+ ref={ref}
51
+ className={cn(
52
+ 'relative overflow-hidden rounded-3xl border border-gray-200 shadow-sm dark:border-border',
53
+ 'bg-[#E0DFDB] dark:bg-card',
54
+ 'transition-[opacity,transform] duration-700 ease-out',
55
+ visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6',
56
+ )}
57
+ >
58
+ {/* Decorative icon */}
59
+ <div
60
+ className="pointer-events-none absolute inset-0 flex items-center justify-center"
61
+ style={{
62
+ opacity: visible ? 0.12 : 0,
63
+ transform: visible ? 'scale(1) rotate(0deg)' : 'scale(0.9) rotate(-5deg)',
64
+ transition: 'opacity 700ms ease 200ms, transform 700ms ease 200ms',
65
+ }}
66
+ >
67
+ {icon ?? <Rocket className="size-72 text-foreground dark:text-foreground" strokeWidth={0.75} />}
68
+ </div>
69
+
70
+ {/* Content */}
71
+ <div className="relative z-10 px-6 py-20 text-center sm:px-10 sm:py-28">
72
+ <h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
73
+ {heading}
74
+ </h2>
75
+ {description && (
76
+ <p className="mx-auto mt-4 max-w-lg text-pretty text-muted-foreground">{description}</p>
77
+ )}
78
+ <div className="mt-8">
79
+ {renderLink({
80
+ href: primaryCta.href,
81
+ children: (
82
+ <span className="inline-flex items-center gap-2 rounded-full bg-foreground px-6 py-3 text-sm font-medium text-background transition-opacity hover:opacity-80">
83
+ {primaryCta.label}
84
+ <ArrowRight className="size-4" />
85
+ </span>
86
+ ),
87
+ })}
88
+ </div>
89
+ {footnote && (
90
+ <p className="mt-5 text-sm text-muted-foreground">{footnote}</p>
91
+ )}
92
+ </div>
93
+ </section>
94
+ );
95
+ }
@@ -0,0 +1,120 @@
1
+ 'use client';
2
+
3
+ import type { LucideIcon } from 'lucide-react';
4
+ import { cn, useUIVariant } from '@olwiba/cn';
5
+ import { SectionTitle } from './SectionTitle';
6
+ import { FadeIn } from '../motion/FadeIn';
7
+
8
+ export interface FeatureMarqueeItem {
9
+ icon: LucideIcon;
10
+ title: string;
11
+ }
12
+
13
+ export interface FeatureMarqueeRow {
14
+ items: FeatureMarqueeItem[];
15
+ direction?: 'left' | 'right';
16
+ }
17
+
18
+ export interface FeatureMarqueeSectionProps {
19
+ badge?: string;
20
+ title?: string;
21
+ description?: string;
22
+ rows: FeatureMarqueeRow[];
23
+ speed?: 'slow' | 'normal' | 'fast';
24
+ pauseOnHover?: boolean;
25
+ }
26
+
27
+ const speedDuration = {
28
+ slow: '280s',
29
+ normal: '180s',
30
+ fast: '100s',
31
+ } as const;
32
+
33
+ export function FeatureMarqueeSection({
34
+ badge,
35
+ title,
36
+ description,
37
+ rows,
38
+ speed = 'normal',
39
+ pauseOnHover = true,
40
+ }: FeatureMarqueeSectionProps) {
41
+ const mode = useUIVariant();
42
+ const duration = speedDuration[speed];
43
+
44
+ const sectionClasses = cn(
45
+ 'overflow-hidden bg-card',
46
+ mode === 'smooth' && 'rounded-3xl border',
47
+ mode === 'playful' && 'rounded-2xl border-primary/25 border',
48
+ !mode && 'rounded-2xl border',
49
+ );
50
+
51
+ return (
52
+ <section className={sectionClasses}>
53
+ <div className="py-14 sm:py-20">
54
+ {(title || badge || description) && (
55
+ <FadeIn direction="up">
56
+ <div className="mx-auto mb-10 max-w-4xl px-6 sm:px-10">
57
+ <SectionTitle title={title ?? ''} description={description} badge={badge} />
58
+ </div>
59
+ </FadeIn>
60
+ )}
61
+
62
+ <div className="space-y-3">
63
+ {rows.map((row, i) => {
64
+ const direction = row.direction ?? (i % 2 === 0 ? 'left' : 'right');
65
+ const reversed = direction === 'right';
66
+ // For right-scroll rows: reverse source order so item 0 starts on the right
67
+ // and items enter from the right in natural reading order.
68
+ const source = reversed ? [...row.items].reverse() : row.items;
69
+ // Repeat enough that one set exceeds 4K viewport (~3840px).
70
+ const set = Array.from({ length: 4 }, () => source).flat();
71
+ const items = [...set, ...set];
72
+
73
+ return (
74
+ <div
75
+ key={i}
76
+ className="group relative overflow-hidden"
77
+ style={{
78
+ maskImage:
79
+ 'linear-gradient(to right, transparent, black 8%, black 92%, transparent)',
80
+ WebkitMaskImage:
81
+ 'linear-gradient(to right, transparent, black 8%, black 92%, transparent)',
82
+ }}
83
+ >
84
+ <div
85
+ className={cn(
86
+ 'flex w-max gap-3 py-0.5',
87
+ pauseOnHover && 'group-hover:[animation-play-state:paused]',
88
+ )}
89
+ style={{
90
+ animation: `feature-marquee-left ${duration} linear infinite`,
91
+ }}
92
+ >
93
+ {items.map((item, j) => {
94
+ const Icon = item.icon;
95
+ return (
96
+ <div
97
+ key={`${item.title}-${j}`}
98
+ className="flex shrink-0 items-center gap-2 rounded-full border bg-background px-4 py-2 text-sm shadow-sm"
99
+ >
100
+ <Icon className="size-3.5 shrink-0 text-primary" />
101
+ <span className="whitespace-nowrap text-foreground">{item.title}</span>
102
+ </div>
103
+ );
104
+ })}
105
+ </div>
106
+ </div>
107
+ );
108
+ })}
109
+ </div>
110
+ </div>
111
+
112
+ <style>{`
113
+ @keyframes feature-marquee-left {
114
+ from { transform: translateX(0); }
115
+ to { transform: translateX(-50%); }
116
+ }
117
+ `}</style>
118
+ </section>
119
+ );
120
+ }