@olwiba/ui 0.1.15 → 0.2.0

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.
@@ -0,0 +1,146 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Bell, Inbox } from 'lucide-react';
5
+ import {
6
+ Avatar,
7
+ AvatarFallback,
8
+ AvatarImage,
9
+ Popover,
10
+ PopoverContent,
11
+ PopoverTrigger,
12
+ cn,
13
+ } from '@olwiba/cn';
14
+ import { Button } from '../primitives/Button';
15
+
16
+ export interface NotificationItem {
17
+ id: string;
18
+ title: string;
19
+ description?: string;
20
+ /** Pre-formatted timestamp, e.g. "2h ago" or "Yesterday". */
21
+ timestamp?: string;
22
+ read?: boolean;
23
+ /** Avatar image — takes precedence over `icon`. */
24
+ avatar?: string;
25
+ icon?: React.ReactNode;
26
+ }
27
+
28
+ export interface NotificationsPopoverProps {
29
+ notifications: NotificationItem[];
30
+ onNotificationClick?: (notification: NotificationItem) => void;
31
+ /** Shows a "Mark all read" action in the header when there are unread items. */
32
+ onMarkAllRead?: () => void;
33
+ title?: string;
34
+ emptyMessage?: string;
35
+ /** Popover alignment relative to the bell button. @default 'end' */
36
+ align?: 'start' | 'center' | 'end';
37
+ /** Controlled open state — omit to let the component manage it internally. */
38
+ open?: boolean;
39
+ onOpenChange?: (open: boolean) => void;
40
+ className?: string;
41
+ }
42
+
43
+ /**
44
+ * Bell button + persistent notification inbox. Complements `notify()` toasts:
45
+ * a toast announces an event as it happens, this popover holds the history.
46
+ * Presentation-only — pass `notifications` from your own data layer and
47
+ * persist read state via `onMarkAllRead`/`onNotificationClick`.
48
+ */
49
+ export function NotificationsPopover({
50
+ notifications,
51
+ onNotificationClick,
52
+ onMarkAllRead,
53
+ title = 'Notifications',
54
+ emptyMessage = 'Nothing new — you’re all caught up.',
55
+ align = 'end',
56
+ open,
57
+ onOpenChange,
58
+ className,
59
+ }: NotificationsPopoverProps) {
60
+ const unreadCount = notifications.filter((n) => !n.read).length;
61
+
62
+ return (
63
+ <Popover open={open} onOpenChange={onOpenChange}>
64
+ <PopoverTrigger asChild>
65
+ <Button variant="ghost" size="icon" className={cn('relative size-8', className)}>
66
+ <Bell className="size-4" />
67
+ {unreadCount > 0 && (
68
+ <span className="absolute -right-0.5 -top-0.5 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-primary-foreground">
69
+ {unreadCount > 9 ? '9+' : unreadCount}
70
+ </span>
71
+ )}
72
+ <span className="sr-only">
73
+ {title}{unreadCount > 0 ? ` (${unreadCount} unread)` : ''}
74
+ </span>
75
+ </Button>
76
+ </PopoverTrigger>
77
+ <PopoverContent align={align} className="w-80 p-0">
78
+ <div className="flex items-center justify-between border-b px-4 py-3">
79
+ <p className="text-sm font-semibold">{title}</p>
80
+ {onMarkAllRead && unreadCount > 0 && (
81
+ <button
82
+ type="button"
83
+ onClick={onMarkAllRead}
84
+ className="text-xs font-medium text-muted-foreground hover:text-foreground"
85
+ >
86
+ Mark all read
87
+ </button>
88
+ )}
89
+ </div>
90
+
91
+ {notifications.length === 0 ? (
92
+ <div className="flex flex-col items-center gap-2 px-4 py-10 text-center">
93
+ <Inbox className="size-6 text-muted-foreground" />
94
+ <p className="text-sm text-muted-foreground">{emptyMessage}</p>
95
+ </div>
96
+ ) : (
97
+ <ul className="max-h-80 overflow-y-auto">
98
+ {notifications.map((notification) => (
99
+ <li key={notification.id} className="border-b last:border-b-0">
100
+ <button
101
+ type="button"
102
+ onClick={() => onNotificationClick?.(notification)}
103
+ className={cn(
104
+ 'flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60',
105
+ !notification.read && 'bg-muted/40',
106
+ )}
107
+ >
108
+ <span
109
+ aria-hidden
110
+ className={cn(
111
+ 'mt-1.5 size-2 shrink-0 rounded-full',
112
+ notification.read ? 'bg-transparent' : 'bg-primary',
113
+ )}
114
+ />
115
+ {notification.avatar ? (
116
+ <Avatar className="size-8 shrink-0">
117
+ <AvatarImage src={notification.avatar} alt="" />
118
+ <AvatarFallback>{notification.title.slice(0, 2).toUpperCase()}</AvatarFallback>
119
+ </Avatar>
120
+ ) : notification.icon ? (
121
+ <span className="mt-0.5 shrink-0 text-muted-foreground">{notification.icon}</span>
122
+ ) : null}
123
+ <span className="min-w-0 flex-1">
124
+ <span className={cn('block truncate text-sm', !notification.read && 'font-medium')}>
125
+ {notification.title}
126
+ </span>
127
+ {notification.description && (
128
+ <span className="mt-0.5 line-clamp-2 block text-xs text-muted-foreground">
129
+ {notification.description}
130
+ </span>
131
+ )}
132
+ {notification.timestamp && (
133
+ <span className="mt-1 block text-xs text-muted-foreground/70">
134
+ {notification.timestamp}
135
+ </span>
136
+ )}
137
+ </span>
138
+ </button>
139
+ </li>
140
+ ))}
141
+ </ul>
142
+ )}
143
+ </PopoverContent>
144
+ </Popover>
145
+ );
146
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from 'react';
4
4
  import { toast } from 'sonner';
5
- import { CheckCircle2, Inbox, Info, X } from 'lucide-react';
5
+ import { AlertCircle, AlertTriangle, CheckCircle2, Inbox, Info, X } from 'lucide-react';
6
6
  import { cn } from '@olwiba/cn';
7
7
  import { Button } from '../primitives/Button';
8
8
 
@@ -12,7 +12,7 @@ export interface NotifyAction {
12
12
  }
13
13
 
14
14
  export interface NotificationToastProps {
15
- variant?: 'success' | 'info' | 'message';
15
+ variant?: 'success' | 'info' | 'warning' | 'error' | 'message';
16
16
  title: string;
17
17
  description?: string;
18
18
  /** Avatar image — overrides the variant icon (e.g. for a message-from-a-person toast). */
@@ -27,6 +27,8 @@ export interface NotificationToastProps {
27
27
  const variantIcon = {
28
28
  success: <CheckCircle2 className="size-5 text-primary" />,
29
29
  info: <Info className="size-5 text-muted-foreground" />,
30
+ warning: <AlertTriangle className="size-5 text-amber-500 dark:text-amber-400" />,
31
+ error: <AlertCircle className="size-5 text-destructive" />,
30
32
  message: <Inbox className="size-5 text-muted-foreground" />,
31
33
  } as const;
32
34
 
package/src/index.ts CHANGED
@@ -84,10 +84,8 @@ export { FeaturesSection, type FeaturesSectionProps } from './marketing/Features
84
84
  export { GroupedFeaturesSection, type GroupedFeaturesSectionProps, type GroupedFeatureGroup } from './marketing/GroupedFeaturesSection';
85
85
  export { StepsSection, type StepsSectionProps, type StepItem } from './marketing/StepsSection';
86
86
  export { TechStackSection, type TechStackSectionProps, type TechStackItem } from './marketing/TechStackSection';
87
- export { CarouselSection, type CarouselSectionProps } from './marketing/CarouselSection';
88
87
  export { FeatureMarqueeSection, type FeatureMarqueeSectionProps, type FeatureMarqueeRow, type FeatureMarqueeItem } from './marketing/FeatureMarqueeSection';
89
88
  export { CtaSection, type CtaSectionProps } from './marketing/CtaSection';
90
- export { CtaCardSection, type CtaCardSectionProps } from './marketing/CtaCardSection';
91
89
  export { PricingSection, type PricingSectionProps, type PricingPlan } from './marketing/PricingSection';
92
90
  export { TestimonialsSection, type TestimonialsSectionProps } from './marketing/TestimonialsSection';
93
91
  export { TeamSection, type TeamMember, type TeamSectionProps } from './marketing/TeamSection';
@@ -115,6 +113,9 @@ export { CountdownTimer, type CountdownTimerProps } from './motion/CountdownTime
115
113
  export { AnimatedPill, type AnimatedPillProps } from './motion/AnimatedPill';
116
114
  export { PageTransition, type PageTransitionProps } from './motion/PageTransition';
117
115
 
116
+ // ─── Mechanics — behavior wrappers that play any children ────────────────────
117
+ export { Carousel, type CarouselProps } from './mechanics/Carousel';
118
+
118
119
  // ─── Components — interactive ────────────────────────────────────────────────
119
120
  export { Spotlight, type SpotlightProps, type SpotlightGroup, type SpotlightItem } from './components/Spotlight';
120
121
  export { Dock, type DockProps, type DockItem } from './components/Dock';
@@ -128,6 +129,8 @@ export { FileUpload, type FileUploadProps, type FileUploadEntry } from './compon
128
129
 
129
130
  // ─── Components — notifications ──────────────────────────────────────────────
130
131
  export { NotificationToast, type NotificationToastProps, notify, type NotifyOptions, type NotifyAction } from './components/Notify';
132
+ export { NotificationsPopover, type NotificationsPopoverProps, type NotificationItem } from './components/NotificationsPopover';
133
+ export { ActivityFeed, type ActivityFeedProps, type ActivityFeedItem } from './components/ActivityFeed';
131
134
 
132
135
  // ─── Components — device mockups ────────────────────────────────────────────
133
136
  export { PhoneFrame, type PhoneFrameProps } from './components/PhoneFrame';
@@ -2,7 +2,11 @@
2
2
 
3
3
  import * as React from 'react';
4
4
  import { ChevronDown, Mail, MessageSquare, Send, type LucideIcon } from 'lucide-react';
5
- import { Badge, Button, Input, Label, Textarea } from '@olwiba/cn';
5
+ import { cn, Label, useUIVariant } from '@olwiba/cn';
6
+ import { Badge } from '../primitives/Badge';
7
+ import { Button } from '../primitives/Button';
8
+ import { Input } from '../primitives/Input';
9
+ import { Textarea } from '../primitives/Textarea';
6
10
 
7
11
  export type ContactInfoItem = {
8
12
  label: string;
@@ -53,6 +57,13 @@ export function ContactSection({
53
57
  onSubmit,
54
58
  }: ContactSectionProps = {}) {
55
59
  const [submitted, setSubmitted] = React.useState(false);
60
+ const mode = useUIVariant();
61
+ const sectionClasses = cn(
62
+ 'overflow-hidden bg-card',
63
+ mode === 'smooth' && 'rounded-3xl border',
64
+ mode === 'playful' && 'rounded-2xl border-primary/25 border',
65
+ !mode && 'rounded-2xl border',
66
+ );
56
67
 
57
68
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
58
69
  e.preventDefault();
@@ -61,7 +72,7 @@ export function ContactSection({
61
72
  }
62
73
 
63
74
  return (
64
- <section className="overflow-hidden rounded-2xl border bg-card">
75
+ <section className={sectionClasses}>
65
76
  <div className="px-6 py-14 sm:px-10 sm:py-20">
66
77
 
67
78
  {/* Header */}
@@ -1,16 +1,28 @@
1
1
  'use client';
2
2
 
3
- import { ArrowRight, Sparkles } from 'lucide-react';
4
- import { Button, cn, useUIVariant } from '@olwiba/cn';
3
+ import * as React from 'react';
4
+ import { ArrowRight, Rocket, Sparkles } from 'lucide-react';
5
+ import { cn, useUIVariant } from '@olwiba/cn';
6
+ import { Button } from '../primitives/Button';
5
7
  import { FadeIn } from '../motion/FadeIn';
8
+ import { useIntersectionObserver } from '../hooks/use-intersection-observer';
6
9
  import type { AppShellRenderLink } from '../app/AppShell';
7
10
 
8
11
  export interface CtaSectionProps {
9
12
  heading: string;
10
- description: string;
13
+ description?: string;
11
14
  primaryCta: { label: string; href: string };
12
15
  secondaryCta?: { label: string; href: string };
13
16
  footnote?: string;
17
+ /**
18
+ * Visual treatment of the section.
19
+ * - `'default'` — badge icon, radial glow, primary + secondary CTAs
20
+ * - `'showcase'` — large watermark icon with a scroll-reveal, single pill CTA
21
+ * @default 'default'
22
+ */
23
+ variant?: 'default' | 'showcase';
24
+ /** (showcase) Watermark icon rendered behind the content. @default <Rocket /> */
25
+ icon?: React.ReactNode;
14
26
  renderLink?: AppShellRenderLink;
15
27
  }
16
28
 
@@ -18,14 +30,79 @@ const defaultRenderLink: AppShellRenderLink = ({ href, children, className }) =>
18
30
  <a href={href} className={className}>{children}</a>
19
31
  );
20
32
 
21
- export function CtaSection({
33
+ function ShowcaseCta({
22
34
  heading,
23
35
  description,
24
36
  primaryCta,
25
- secondaryCta,
26
37
  footnote,
38
+ icon,
27
39
  renderLink = defaultRenderLink,
28
- }: CtaSectionProps) {
40
+ sectionClasses,
41
+ }: CtaSectionProps & { sectionClasses: string }) {
42
+ const [ref, intersecting] = useIntersectionObserver({ threshold: 0.15 });
43
+ const [visible, setVisible] = React.useState(false);
44
+
45
+ React.useEffect(() => {
46
+ if (intersecting) setVisible(true);
47
+ }, [intersecting]);
48
+
49
+ return (
50
+ <section
51
+ ref={ref as React.RefObject<HTMLElement>}
52
+ className={cn(
53
+ sectionClasses,
54
+ 'relative transition-[opacity,transform] duration-700 ease-out',
55
+ visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6',
56
+ )}
57
+ >
58
+ {/* Decorative watermark icon */}
59
+ <div
60
+ className="pointer-events-none absolute inset-0 flex items-center justify-center"
61
+ style={{
62
+ opacity: visible ? 0.1 : 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" strokeWidth={0.75} />}
68
+ </div>
69
+
70
+ <div className="relative z-10 px-6 py-20 text-center sm:px-10 sm:py-28">
71
+ <h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
72
+ {heading}
73
+ </h2>
74
+ {description && (
75
+ <p className="mx-auto mt-4 max-w-lg text-pretty text-muted-foreground">{description}</p>
76
+ )}
77
+ <div className="mt-8">
78
+ {renderLink({
79
+ href: primaryCta.href,
80
+ children: (
81
+ <Button size="lg" className="rounded-full px-6">
82
+ {primaryCta.label}
83
+ <ArrowRight className="ml-2 size-4" />
84
+ </Button>
85
+ ),
86
+ })}
87
+ </div>
88
+ {footnote && (
89
+ <p className="mt-5 text-sm text-muted-foreground">{footnote}</p>
90
+ )}
91
+ </div>
92
+ </section>
93
+ );
94
+ }
95
+
96
+ export function CtaSection(props: CtaSectionProps) {
97
+ const {
98
+ heading,
99
+ description,
100
+ primaryCta,
101
+ secondaryCta,
102
+ footnote,
103
+ variant = 'default',
104
+ renderLink = defaultRenderLink,
105
+ } = props;
29
106
  const mode = useUIVariant()
30
107
  const sectionClasses = cn(
31
108
  'overflow-hidden bg-card',
@@ -33,6 +110,11 @@ export function CtaSection({
33
110
  mode === 'playful' && 'rounded-2xl border-primary/25 border',
34
111
  !mode && 'rounded-2xl border',
35
112
  )
113
+
114
+ if (variant === 'showcase') {
115
+ return <ShowcaseCta {...props} sectionClasses={sectionClasses} />;
116
+ }
117
+
36
118
  return (
37
119
  <section className={sectionClasses}>
38
120
  <div className="relative px-6 py-16 sm:px-10 sm:py-24">
@@ -45,9 +127,11 @@ export function CtaSection({
45
127
  <h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
46
128
  {heading}
47
129
  </h2>
48
- <p className="mx-auto mt-4 max-w-lg text-pretty text-muted-foreground">
49
- {description}
50
- </p>
130
+ {description && (
131
+ <p className="mx-auto mt-4 max-w-lg text-pretty text-muted-foreground">
132
+ {description}
133
+ </p>
134
+ )}
51
135
  <div className="mt-8 flex flex-wrap items-center justify-center gap-3">
52
136
  {renderLink({
53
137
  href: primaryCta.href,
@@ -3,14 +3,18 @@
3
3
  import type { LucideIcon } from 'lucide-react';
4
4
  import { cn, useUIVariant } from '@olwiba/cn';
5
5
  import { FeatureCard } from '../components/FeatureCard';
6
+ import { Carousel } from '../mechanics/Carousel';
6
7
  import { SectionTitle } from './SectionTitle';
7
8
  import { StaggerChildren } from '../motion/StaggerChildren';
9
+ import { FadeIn } from '../motion/FadeIn';
8
10
 
9
11
  export interface FeaturesSectionProps {
10
12
  title?: string;
11
13
  description?: string;
12
14
  badge?: string;
13
15
  features: Array<{ icon: LucideIcon; title: string; description: string; href?: string }>;
16
+ /** How the feature cards are arranged. @default 'grid' */
17
+ layout?: 'grid' | 'carousel';
14
18
  }
15
19
 
16
20
  export function FeaturesSection({
@@ -18,6 +22,7 @@ export function FeaturesSection({
18
22
  description = 'A complete system of components, blocks, and hooks designed to work together - and get out of your way.',
19
23
  badge = 'Features',
20
24
  features,
25
+ layout = 'grid',
21
26
  }: FeaturesSectionProps) {
22
27
  const mode = useUIVariant()
23
28
  const sectionClasses = cn(
@@ -26,23 +31,33 @@ export function FeaturesSection({
26
31
  mode === 'playful' && 'rounded-2xl border-primary/25 border',
27
32
  !mode && 'rounded-2xl border',
28
33
  )
34
+ const cards = features.map((feature) => (
35
+ <FeatureCard
36
+ key={feature.title}
37
+ icon={feature.icon}
38
+ title={feature.title}
39
+ description={feature.description}
40
+ href={feature.href}
41
+ />
42
+ ));
43
+
29
44
  return (
30
45
  <section className={sectionClasses}>
31
46
  <div className="px-6 py-14 sm:px-10 sm:py-20">
32
- <div className="mx-auto max-w-4xl">
47
+ <div className={cn('mx-auto', layout === 'carousel' ? 'max-w-5xl' : 'max-w-4xl')}>
33
48
  <SectionTitle title={title} description={description} badge={badge} />
34
49
 
35
- <StaggerChildren className="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
36
- {features.map((feature) => (
37
- <FeatureCard
38
- key={feature.title}
39
- icon={feature.icon}
40
- title={feature.title}
41
- description={feature.description}
42
- href={feature.href}
43
- />
44
- ))}
45
- </StaggerChildren>
50
+ {layout === 'carousel' ? (
51
+ <FadeIn direction="up">
52
+ <Carousel className="mt-10" ariaLabel={title}>
53
+ {cards}
54
+ </Carousel>
55
+ </FadeIn>
56
+ ) : (
57
+ <StaggerChildren className="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
58
+ {cards}
59
+ </StaggerChildren>
60
+ )}
46
61
  </div>
47
62
  </div>
48
63
  </section>
@@ -2,7 +2,9 @@
2
2
 
3
3
  import * as React from 'react';
4
4
  import { ArrowRight } from 'lucide-react';
5
- import { Avatar, AvatarFallback, AvatarImage, Badge, Button } from '@olwiba/cn';
5
+ import { Avatar, AvatarFallback, AvatarImage, cn, useUIVariant } from '@olwiba/cn';
6
+ import { Badge } from '../primitives/Badge';
7
+ import { Button } from '../primitives/Button';
6
8
  import { FadeIn } from '../motion/FadeIn';
7
9
  import { PhoneFrame } from '../components/PhoneFrame';
8
10
  import type { AppShellRenderLink } from '../app/AppShell';
@@ -39,6 +41,7 @@ export function HeroSection({
39
41
  socialProofText,
40
42
  renderLink = defaultRenderLink,
41
43
  }: HeroSectionProps) {
44
+ const mode = useUIVariant();
42
45
  return (
43
46
  <section className="overflow-hidden">
44
47
  <div className={marketingSectionSpacing.hero}>
@@ -90,7 +93,7 @@ export function HeroSection({
90
93
  </PhoneFrame>
91
94
  </div>
92
95
  ) : (
93
- <div className="overflow-hidden rounded-2xl">
96
+ <div className={cn('overflow-hidden', mode === 'smooth' ? 'rounded-3xl' : 'rounded-2xl')}>
94
97
  {heroImage}
95
98
  </div>
96
99
  )}
@@ -2,7 +2,10 @@
2
2
 
3
3
  import * as React from 'react';
4
4
  import { ArrowRight, Mail } from 'lucide-react';
5
- import { Badge, Button, Input } from '@olwiba/cn';
5
+ import { cn, useUIVariant } from '@olwiba/cn';
6
+ import { Badge } from '../primitives/Badge';
7
+ import { Button } from '../primitives/Button';
8
+ import { Input } from '../primitives/Input';
6
9
 
7
10
  export interface NewsletterSectionProps {
8
11
  badge?: string;
@@ -44,6 +47,13 @@ export function NewsletterSection({
44
47
  }: NewsletterSectionProps = {}) {
45
48
  const [email, setEmail] = React.useState('');
46
49
  const [submitted, setSubmitted] = React.useState(false);
50
+ const mode = useUIVariant();
51
+ const sectionClasses = cn(
52
+ 'overflow-hidden bg-card',
53
+ mode === 'smooth' && 'rounded-3xl border',
54
+ mode === 'playful' && 'rounded-2xl border-primary/25 border',
55
+ !mode && 'rounded-2xl border',
56
+ );
47
57
 
48
58
  async function handleSubmit(e: React.FormEvent) {
49
59
  e.preventDefault();
@@ -53,7 +63,7 @@ export function NewsletterSection({
53
63
  }
54
64
 
55
65
  return (
56
- <section className="overflow-hidden rounded-2xl border bg-card">
66
+ <section className={sectionClasses}>
57
67
  <div className="relative px-6 py-14 sm:px-10 sm:py-20">
58
68
  <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,hsl(var(--primary)/0.1),transparent_60%)]" />
59
69
  <div className="relative mx-auto max-w-xl text-center">
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
 
3
3
  import { Github, Linkedin, Twitter } from 'lucide-react';
4
- import { Avatar, AvatarFallback, AvatarImage, Badge } from '@olwiba/cn';
4
+ import { Avatar, AvatarFallback, AvatarImage, cn, useUIVariant } from '@olwiba/cn';
5
+ import { Badge } from '../primitives/Badge';
5
6
 
6
7
  export interface TeamMember {
7
8
  name: string;
@@ -56,8 +57,15 @@ export function TeamSection({
56
57
  title = 'The people behind Olwiba',
57
58
  description = 'A small team with deep experience in design systems, open source, and developer tooling.',
58
59
  }: TeamSectionProps) {
60
+ const mode = useUIVariant();
61
+ const sectionClasses = cn(
62
+ 'overflow-hidden bg-card',
63
+ mode === 'smooth' && 'rounded-3xl border',
64
+ mode === 'playful' && 'rounded-2xl border-primary/25 border',
65
+ !mode && 'rounded-2xl border',
66
+ );
59
67
  return (
60
- <section className="overflow-hidden rounded-2xl border bg-card">
68
+ <section className={sectionClasses}>
61
69
  <div className="px-6 py-14 sm:px-10 sm:py-20">
62
70
  <div className="mx-auto max-w-5xl">
63
71
  <div className="text-center">
@@ -0,0 +1,112 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
5
+ import { cn, useUIVariant } from '@olwiba/cn';
6
+
7
+ export interface CarouselProps {
8
+ /** Items to scroll through — each child becomes one snap slide. */
9
+ children: React.ReactNode;
10
+ /** Width classes applied to each slide. @default 'w-[280px] sm:w-[320px]' */
11
+ itemClassName?: string;
12
+ /** Prev/next control placement. @default 'top-right' */
13
+ controls?: 'top-right' | 'none';
14
+ /** Accessible label for the scroll region. */
15
+ ariaLabel?: string;
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * Scroll-snap carousel behavior — wraps any children in a horizontally
21
+ * scrolling track with prev/next controls. A mechanic, not a section:
22
+ * feed it cards, images, or whole blocks. No carousel dependency.
23
+ */
24
+ export function Carousel({
25
+ children,
26
+ itemClassName = 'w-[280px] sm:w-[320px]',
27
+ controls = 'top-right',
28
+ ariaLabel,
29
+ className,
30
+ }: CarouselProps) {
31
+ const mode = useUIVariant();
32
+ const trackRef = React.useRef<HTMLDivElement>(null);
33
+ const [canPrev, setCanPrev] = React.useState(false);
34
+ const [canNext, setCanNext] = React.useState(true);
35
+
36
+ const updateScrollState = React.useCallback(() => {
37
+ const track = trackRef.current;
38
+ if (!track) return;
39
+ setCanPrev(track.scrollLeft > 8);
40
+ setCanNext(track.scrollLeft < track.scrollWidth - track.clientWidth - 8);
41
+ }, []);
42
+
43
+ React.useEffect(() => {
44
+ const track = trackRef.current;
45
+ if (!track) return;
46
+ updateScrollState();
47
+ track.addEventListener('scroll', updateScrollState, { passive: true });
48
+ const observer = new ResizeObserver(updateScrollState);
49
+ observer.observe(track);
50
+ return () => {
51
+ track.removeEventListener('scroll', updateScrollState);
52
+ observer.disconnect();
53
+ };
54
+ }, [updateScrollState]);
55
+
56
+ const scrollByItem = (direction: 1 | -1) => {
57
+ const track = trackRef.current;
58
+ if (!track) return;
59
+ const item = track.querySelector<HTMLElement>('[data-carousel-item]');
60
+ const gap = parseFloat(getComputedStyle(track).columnGap) || 24;
61
+ const step = item ? item.offsetWidth + gap : track.clientWidth * 0.8;
62
+ track.scrollBy({ left: direction * step, behavior: 'smooth' });
63
+ };
64
+
65
+ const controlClasses = cn(
66
+ 'flex size-9 items-center justify-center border bg-background text-foreground transition-colors',
67
+ 'hover:bg-muted disabled:opacity-40 disabled:hover:bg-background',
68
+ mode === 'smooth' ? 'rounded-2xl' : 'rounded-xl',
69
+ );
70
+
71
+ return (
72
+ <div className={className}>
73
+ {controls === 'top-right' && (
74
+ <div className="mb-4 flex items-center justify-end gap-2">
75
+ <button
76
+ type="button"
77
+ aria-label="Previous"
78
+ className={controlClasses}
79
+ onClick={() => scrollByItem(-1)}
80
+ disabled={!canPrev}
81
+ >
82
+ <ChevronLeft className="size-4" />
83
+ </button>
84
+ <button
85
+ type="button"
86
+ aria-label="Next"
87
+ className={controlClasses}
88
+ onClick={() => scrollByItem(1)}
89
+ disabled={!canNext}
90
+ >
91
+ <ChevronRight className="size-4" />
92
+ </button>
93
+ </div>
94
+ )}
95
+
96
+ <div
97
+ ref={trackRef}
98
+ role="region"
99
+ aria-label={ariaLabel}
100
+ className="flex snap-x snap-mandatory gap-6 overflow-x-auto pb-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
101
+ >
102
+ {React.Children.map(children, (child) =>
103
+ child == null ? null : (
104
+ <div data-carousel-item className={cn('shrink-0 snap-start', itemClassName)}>
105
+ {child}
106
+ </div>
107
+ ),
108
+ )}
109
+ </div>
110
+ </div>
111
+ );
112
+ }