@vvedantb/ui 0.8.0 → 0.9.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.
- package/README.md +89 -1
- package/app/README.md +193 -0
- package/package.json +14 -11
- package/shell/src/SidebarAccountMenu.tsx +78 -2
- package/shell/src/SidebarRail.tsx +20 -0
- package/shell/src/index.ts +2 -2
- package/src/index.ts +52 -2
- package/src/marketing/PricingModal.tsx +126 -0
- package/src/marketing/SiteFooter.tsx +135 -0
- package/src/marketing/createLandingKit.tsx +214 -0
- package/src/marketing/landing.css +271 -0
- package/src/marketing/landing.tsx +217 -0
- package/src/marketing/marketing-classes.ts +115 -0
- package/tokens/README.md +98 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import { IconCheck, IconX } from "@tabler/icons-react";
|
|
3
|
+
import { Button } from "../ui/button";
|
|
4
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../ui/dialog";
|
|
5
|
+
import { VisuallyHidden } from "../ui/visually-hidden";
|
|
6
|
+
import { pricingModalClasses, type PricingModalTone } from "./marketing-classes";
|
|
7
|
+
|
|
8
|
+
export type PricingModalProps = {
|
|
9
|
+
open: boolean;
|
|
10
|
+
/** Called when the dialog asks to close and by the `closeButton`. */
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
/** Accessible dialog title, for example "Pro". Not visible. */
|
|
13
|
+
title: string;
|
|
14
|
+
/** Accessible description. Not visible. When set, the title is an `sr-only` `DialogTitle` followed by this. */
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Logo before the wordmark: `dark` shows in dark mode, `light` in light mode. 28px. */
|
|
17
|
+
logo: { dark: string; light: string };
|
|
18
|
+
/** Text after the logo, before the PRO badge. */
|
|
19
|
+
wordmark: string;
|
|
20
|
+
/** For example "$9". */
|
|
21
|
+
price: string;
|
|
22
|
+
tagline: ReactNode;
|
|
23
|
+
/** Extra font class on the tagline, placed after `max-w-[28ch]`. */
|
|
24
|
+
taglineFontClassName?: string;
|
|
25
|
+
/** Plan features under "What you get". */
|
|
26
|
+
features: readonly string[];
|
|
27
|
+
/** Label of the full-width checkout button. Default "Get Pro". */
|
|
28
|
+
ctaLabel?: string;
|
|
29
|
+
/** Wraps the checkout button, for example in the billing provider's checkout and signed-in gates. */
|
|
30
|
+
renderCheckout: (button: ReactElement) => ReactNode;
|
|
31
|
+
/** Second, compact close button in the top-right corner. Default `false`. */
|
|
32
|
+
closeButton?: boolean;
|
|
33
|
+
/** Secondary text colour class. Default `"muted"`. */
|
|
34
|
+
tone?: PricingModalTone;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Single-plan upgrade dialog: brand header with price and tagline, checkout button and feature list. */
|
|
38
|
+
export function PricingModal({
|
|
39
|
+
open,
|
|
40
|
+
onClose,
|
|
41
|
+
title,
|
|
42
|
+
description,
|
|
43
|
+
logo,
|
|
44
|
+
wordmark,
|
|
45
|
+
price,
|
|
46
|
+
tagline,
|
|
47
|
+
taglineFontClassName,
|
|
48
|
+
features,
|
|
49
|
+
ctaLabel = "Get Pro",
|
|
50
|
+
renderCheckout,
|
|
51
|
+
closeButton = false,
|
|
52
|
+
tone = "muted",
|
|
53
|
+
}: PricingModalProps) {
|
|
54
|
+
const c = pricingModalClasses[tone];
|
|
55
|
+
const taglineClass = ["mt-4 max-w-[28ch]", taglineFontClassName, c.tagline].filter(Boolean).join(" ");
|
|
56
|
+
return (
|
|
57
|
+
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
|
58
|
+
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-md">
|
|
59
|
+
{description ? (
|
|
60
|
+
<>
|
|
61
|
+
<DialogTitle className="sr-only">{title}</DialogTitle>
|
|
62
|
+
<DialogDescription className="sr-only">{description}</DialogDescription>
|
|
63
|
+
</>
|
|
64
|
+
) : (
|
|
65
|
+
<VisuallyHidden>
|
|
66
|
+
<DialogTitle>{title}</DialogTitle>
|
|
67
|
+
</VisuallyHidden>
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
{closeButton ? (
|
|
71
|
+
<button
|
|
72
|
+
onClick={onClose}
|
|
73
|
+
className="absolute right-4 top-4 z-10 flex size-7 items-center justify-center rounded-md text-muted-foreground transition-[background-color,transform] duration-100 ease-out active:scale-[0.96] hover:bg-surface-tertiary hover:text-foreground"
|
|
74
|
+
>
|
|
75
|
+
<IconX className="size-4" />
|
|
76
|
+
<span className="sr-only">Close</span>
|
|
77
|
+
</button>
|
|
78
|
+
) : null}
|
|
79
|
+
|
|
80
|
+
<div className="bg-surface-secondary px-8 pb-8 pt-10 dark:bg-surface-secondary/40">
|
|
81
|
+
<div className="flex items-center">
|
|
82
|
+
<img src={logo.dark} alt="" width={28} height={28} className="hidden dark:block" />
|
|
83
|
+
<img src={logo.light} alt="" width={28} height={28} className="block dark:hidden" />
|
|
84
|
+
<span className="font-michroma text-xl tracking-wider text-foreground">{wordmark}</span>
|
|
85
|
+
<span className="ml-2 rounded-md bg-segment px-2 py-0.5 font-michroma text-[10px] tracking-[0.25em] text-segment-foreground">
|
|
86
|
+
PRO
|
|
87
|
+
</span>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div className="mt-8 flex items-end gap-2">
|
|
91
|
+
<span className="text-5xl font-medium tabular-nums leading-none tracking-tight text-foreground">
|
|
92
|
+
{price}
|
|
93
|
+
</span>
|
|
94
|
+
<span className={c.period}>/ month</span>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<p className={taglineClass}>{tagline}</p>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div className="px-8 pb-8 pt-6">
|
|
101
|
+
{renderCheckout(
|
|
102
|
+
<Button size="lg" className="h-11 w-full rounded-xl">
|
|
103
|
+
{ctaLabel}
|
|
104
|
+
</Button>,
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
<p className={c.note}>Cancel anytime. Billed monthly.</p>
|
|
108
|
+
|
|
109
|
+
<div className="mt-8">
|
|
110
|
+
<p className={c.featuresHeading}>What you get</p>
|
|
111
|
+
<ul className="space-y-3">
|
|
112
|
+
{features.map((feature) => (
|
|
113
|
+
<li key={feature} className="flex items-start gap-3">
|
|
114
|
+
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-md bg-default dark:bg-default/80">
|
|
115
|
+
<IconCheck className="size-3 text-foreground" strokeWidth={2.5} />
|
|
116
|
+
</span>
|
|
117
|
+
<span className="text-sm leading-snug text-foreground">{feature}</span>
|
|
118
|
+
</li>
|
|
119
|
+
))}
|
|
120
|
+
</ul>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</DialogContent>
|
|
124
|
+
</Dialog>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
import { Button } from "../ui/button";
|
|
3
|
+
import { Separator } from "../ui/separator";
|
|
4
|
+
import { siteFooterClasses, type SiteFooterVariant } from "./marketing-classes";
|
|
5
|
+
|
|
6
|
+
export type SiteFooterLink = {
|
|
7
|
+
href: string;
|
|
8
|
+
label: string;
|
|
9
|
+
/** Opens in a new tab (`target="_blank" rel="noopener noreferrer"`). */
|
|
10
|
+
external?: boolean;
|
|
11
|
+
/** 20px round image before the label. */
|
|
12
|
+
image?: { src: string; alt: string };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type SiteFooterSocial = {
|
|
16
|
+
href: string;
|
|
17
|
+
/** Rendered with the variant's `w-4 h-4` muted class, for example a Tabler brand icon. */
|
|
18
|
+
icon: ComponentType<{ className?: string }>;
|
|
19
|
+
/** `aria-label` on the link. */
|
|
20
|
+
label?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SiteFooterProps = {
|
|
24
|
+
/** Wordmark text above the tagline. */
|
|
25
|
+
brand: ReactNode;
|
|
26
|
+
tagline: ReactNode;
|
|
27
|
+
/** Icon buttons under the tagline. Always open in a new tab. */
|
|
28
|
+
socials: readonly SiteFooterSocial[];
|
|
29
|
+
/** Link columns to the right of the brand block. */
|
|
30
|
+
columns: readonly { heading: string; links: readonly SiteFooterLink[] }[];
|
|
31
|
+
/** Name in "Copyright © {year} {name}". */
|
|
32
|
+
name: string;
|
|
33
|
+
/** Default: the current year. */
|
|
34
|
+
year?: number;
|
|
35
|
+
/** Bottom-right links, for example Privacy Policy and Terms of Service. Always open in a new tab. */
|
|
36
|
+
legalLinks: readonly { href: string; label: string }[];
|
|
37
|
+
/**
|
|
38
|
+
* `"muted"` (default) and `"muted-foreground"` pick the secondary text
|
|
39
|
+
* colour class. `"touch"` uses `text-muted-foreground`, `max-sm:hit-target`
|
|
40
|
+
* links and a safe-area bottom padding.
|
|
41
|
+
*/
|
|
42
|
+
variant?: SiteFooterVariant;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Marketing site footer: brand, socials, link columns, then copyright and legal links under a separator. */
|
|
46
|
+
export function SiteFooter({
|
|
47
|
+
brand,
|
|
48
|
+
tagline,
|
|
49
|
+
socials,
|
|
50
|
+
columns,
|
|
51
|
+
name,
|
|
52
|
+
year = new Date().getFullYear(),
|
|
53
|
+
legalLinks,
|
|
54
|
+
variant = "muted",
|
|
55
|
+
}: SiteFooterProps) {
|
|
56
|
+
const c = siteFooterClasses[variant];
|
|
57
|
+
return (
|
|
58
|
+
<footer className={c.footer}>
|
|
59
|
+
<div className="relative z-10">
|
|
60
|
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
61
|
+
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-8 lg:gap-12">
|
|
62
|
+
<div className="flex-1 lg:flex-none">
|
|
63
|
+
<div className="text-lg font-michroma font-normal tracking-wider mb-3 md:mb-4">{brand}</div>
|
|
64
|
+
<p className={c.tagline}>{tagline}</p>
|
|
65
|
+
<div className="flex gap-3 md:gap-4">
|
|
66
|
+
{socials.map(({ href, icon: Icon, label }) => (
|
|
67
|
+
<a key={href} href={href} target="_blank" rel="noopener noreferrer" aria-label={label}>
|
|
68
|
+
<Button variant="ghost" size="icon" className={c.socialButton}>
|
|
69
|
+
<Icon className={c.socialIcon} />
|
|
70
|
+
</Button>
|
|
71
|
+
</a>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div className="flex flex-col sm:flex-row gap-8 md:gap-12 lg:gap-16 xl:gap-20">
|
|
77
|
+
{columns.map((column) => (
|
|
78
|
+
<div key={column.heading} className="flex-1 sm:flex-none">
|
|
79
|
+
<h4 className={c.heading}>{column.heading}</h4>
|
|
80
|
+
<div className={c.links}>
|
|
81
|
+
{column.links.map((link) => (
|
|
82
|
+
<a
|
|
83
|
+
key={link.label}
|
|
84
|
+
href={link.href}
|
|
85
|
+
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
86
|
+
className={link.image ? c.imageLink : c.link}
|
|
87
|
+
>
|
|
88
|
+
{link.image ? (
|
|
89
|
+
<img
|
|
90
|
+
src={link.image.src}
|
|
91
|
+
alt={link.image.alt}
|
|
92
|
+
width={20}
|
|
93
|
+
height={20}
|
|
94
|
+
className="rounded-full object-cover w-5 h-5 md:w-5 md:h-5"
|
|
95
|
+
/>
|
|
96
|
+
) : null}
|
|
97
|
+
{link.label}
|
|
98
|
+
</a>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<Separator className="my-6 md:my-8 bg-separator" />
|
|
107
|
+
|
|
108
|
+
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 sm:gap-0">
|
|
109
|
+
<div className="flex flex-col gap-1 text-center sm:text-left">
|
|
110
|
+
<p className={c.copyright}>
|
|
111
|
+
Copyright © {year}
|
|
112
|
+
{` ${name}`}
|
|
113
|
+
</p>
|
|
114
|
+
<p className={c.rights}>All rights reserved.</p>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className="flex flex-col gap-1 text-center sm:text-right">
|
|
118
|
+
{legalLinks.map((link) => (
|
|
119
|
+
<a
|
|
120
|
+
key={link.href}
|
|
121
|
+
href={link.href}
|
|
122
|
+
target="_blank"
|
|
123
|
+
rel="noopener noreferrer"
|
|
124
|
+
className={c.legalLink}
|
|
125
|
+
>
|
|
126
|
+
{link.label}
|
|
127
|
+
</a>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</footer>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { motion as motionNamespace } from "motion/react";
|
|
3
|
+
import { motionDuration, motionEase } from "../motion/presets";
|
|
4
|
+
import { Button } from "../ui/button";
|
|
5
|
+
import { cn } from "../utils/cn";
|
|
6
|
+
import { landingItemVariants, type LandingLink } from "./landing";
|
|
7
|
+
import { landingClasses, landingShellClass } from "./marketing-classes";
|
|
8
|
+
|
|
9
|
+
/** The `motion` export of `motion/react`. Only these elements are used. */
|
|
10
|
+
export type LandingMotion = Pick<typeof motionNamespace, "div" | "p" | "h1" | "ul" | "header">;
|
|
11
|
+
|
|
12
|
+
export type LandingKitOptions = {
|
|
13
|
+
/** Pass `motion` from `motion/react`. The package does not import Motion itself. */
|
|
14
|
+
motion: LandingMotion;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type LandingRevealProps = {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
className?: string;
|
|
20
|
+
/** Seconds before the first child starts. Default `0`. */
|
|
21
|
+
delay?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type LandingRevealItemProps = { children: ReactNode; className?: string };
|
|
25
|
+
|
|
26
|
+
export type LandingHeroProps = {
|
|
27
|
+
/** Pill above the title, after a pulsing dot. */
|
|
28
|
+
eyebrow: ReactNode;
|
|
29
|
+
title: ReactNode;
|
|
30
|
+
description: ReactNode;
|
|
31
|
+
/** Inside the button row, for example Get started and Sign in. The row renders even when empty. */
|
|
32
|
+
actions?: ReactNode;
|
|
33
|
+
/** Mono pills under the buttons. */
|
|
34
|
+
capabilities: readonly string[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type LandingNavProps = {
|
|
38
|
+
/** Wordmark inside the `#top` home link. */
|
|
39
|
+
brand: ReactNode;
|
|
40
|
+
/** `aria-label` of the home link. */
|
|
41
|
+
homeLabel: string;
|
|
42
|
+
/** Section links, shown from `md`. */
|
|
43
|
+
links: readonly LandingLink[];
|
|
44
|
+
/** Icon button from `sm`. `label` is its `aria-label`. */
|
|
45
|
+
github: { href: string; label: string; icon: ReactNode };
|
|
46
|
+
/** After the GitHub button, for example Sign in and Get started. */
|
|
47
|
+
actions?: ReactNode;
|
|
48
|
+
/** Last in the bar, usually `LandingNavMenu`. */
|
|
49
|
+
menu?: ReactNode;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type LandingCtaProps = {
|
|
53
|
+
title: ReactNode;
|
|
54
|
+
description: ReactNode;
|
|
55
|
+
/** Under the text, for example a large Get started button. */
|
|
56
|
+
action?: ReactNode;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type LandingKit = {
|
|
60
|
+
LandingReveal: (props: LandingRevealProps) => ReactNode;
|
|
61
|
+
LandingRevealItem: (props: LandingRevealItemProps) => ReactNode;
|
|
62
|
+
LandingHero: (props: LandingHeroProps) => ReactNode;
|
|
63
|
+
LandingNav: (props: LandingNavProps) => ReactNode;
|
|
64
|
+
LandingCta: (props: LandingCtaProps) => ReactNode;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const heroContainer = {
|
|
68
|
+
hidden: {},
|
|
69
|
+
show: {
|
|
70
|
+
transition: { staggerChildren: 0.1, delayChildren: 0.08 },
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Builds the animated landing parts on the app's Motion. Call it once at
|
|
76
|
+
* module scope: `export const { LandingHero, ... } = createLandingKit({ motion })`.
|
|
77
|
+
*/
|
|
78
|
+
export function createLandingKit({ motion }: LandingKitOptions): LandingKit {
|
|
79
|
+
/** Staggers its `LandingRevealItem` children in once, when 20% is in view. */
|
|
80
|
+
function LandingReveal({ children, className, delay = 0 }: LandingRevealProps) {
|
|
81
|
+
return (
|
|
82
|
+
<motion.div
|
|
83
|
+
className={className}
|
|
84
|
+
initial="hidden"
|
|
85
|
+
whileInView="show"
|
|
86
|
+
viewport={{ once: true, amount: 0.2 }}
|
|
87
|
+
variants={{
|
|
88
|
+
hidden: {},
|
|
89
|
+
show: {
|
|
90
|
+
transition: { staggerChildren: 0.1, delayChildren: delay },
|
|
91
|
+
},
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
{children}
|
|
95
|
+
</motion.div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function LandingRevealItem({ children, className }: LandingRevealItemProps) {
|
|
100
|
+
return (
|
|
101
|
+
<motion.div className={cn(className)} variants={landingItemVariants}>
|
|
102
|
+
{children}
|
|
103
|
+
</motion.div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Centred hero: eyebrow pill, title, description, actions and capability pills, revealed on mount. */
|
|
108
|
+
function LandingHero({ eyebrow, title, description, actions, capabilities }: LandingHeroProps) {
|
|
109
|
+
return (
|
|
110
|
+
<section className={`${landingShellClass} pb-16 pt-12 sm:pb-20 sm:pt-20 lg:pb-24 lg:pt-24`}>
|
|
111
|
+
<motion.div
|
|
112
|
+
className="mx-auto max-w-3xl text-center"
|
|
113
|
+
initial="hidden"
|
|
114
|
+
animate="show"
|
|
115
|
+
variants={heroContainer}
|
|
116
|
+
>
|
|
117
|
+
<motion.p className={landingClasses.heroEyebrow} variants={landingItemVariants}>
|
|
118
|
+
<span className="landing-pulse-dot size-1.5 rounded-full bg-foreground" aria-hidden />
|
|
119
|
+
{eyebrow}
|
|
120
|
+
</motion.p>
|
|
121
|
+
|
|
122
|
+
<motion.h1
|
|
123
|
+
className="text-balance font-semibold text-[2.35rem] leading-[1] tracking-[-0.035em] text-foreground min-[400px]:text-[2.85rem] sm:text-6xl lg:text-[4.85rem]"
|
|
124
|
+
variants={landingItemVariants}
|
|
125
|
+
transition={{ duration: motionDuration.slow, ease: motionEase }}
|
|
126
|
+
>
|
|
127
|
+
{title}
|
|
128
|
+
</motion.h1>
|
|
129
|
+
|
|
130
|
+
<motion.p className={landingClasses.heroDescription} variants={landingItemVariants}>
|
|
131
|
+
{description}
|
|
132
|
+
</motion.p>
|
|
133
|
+
|
|
134
|
+
<motion.div
|
|
135
|
+
className="mt-7 flex flex-col items-stretch justify-center gap-2.5 sm:mt-9 sm:flex-row sm:items-center sm:gap-3"
|
|
136
|
+
variants={landingItemVariants}
|
|
137
|
+
>
|
|
138
|
+
{actions}
|
|
139
|
+
</motion.div>
|
|
140
|
+
|
|
141
|
+
<motion.ul
|
|
142
|
+
className="mt-8 flex flex-wrap items-center justify-center gap-2 sm:mt-10"
|
|
143
|
+
variants={landingItemVariants}
|
|
144
|
+
>
|
|
145
|
+
{capabilities.map((label) => (
|
|
146
|
+
<li key={label} className={landingClasses.heroCapability}>
|
|
147
|
+
{label}
|
|
148
|
+
</li>
|
|
149
|
+
))}
|
|
150
|
+
</motion.ul>
|
|
151
|
+
</motion.div>
|
|
152
|
+
</section>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Sticky blurred header that rises in on mount. */
|
|
157
|
+
function LandingNav({ brand, homeLabel, links, github, actions, menu }: LandingNavProps) {
|
|
158
|
+
return (
|
|
159
|
+
<motion.header
|
|
160
|
+
className="sticky top-0 z-30 border-b border-separator bg-background/80 backdrop-blur-md"
|
|
161
|
+
initial={{ opacity: 0, y: 14 }}
|
|
162
|
+
animate={{ opacity: 1, y: 0 }}
|
|
163
|
+
transition={{ duration: motionDuration.base, ease: motionEase }}
|
|
164
|
+
>
|
|
165
|
+
<div className={cn(landingShellClass, "flex h-14 items-center justify-between gap-3 sm:h-16")}>
|
|
166
|
+
<a
|
|
167
|
+
href="#top"
|
|
168
|
+
className="group min-w-0 shrink-0 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
169
|
+
aria-label={homeLabel}
|
|
170
|
+
>
|
|
171
|
+
{brand}
|
|
172
|
+
</a>
|
|
173
|
+
|
|
174
|
+
<nav className="hidden items-center gap-1 md:flex" aria-label="Page">
|
|
175
|
+
{links.map((link) => (
|
|
176
|
+
<a key={link.href} href={link.href} className={landingClasses.navLink}>
|
|
177
|
+
{link.label}
|
|
178
|
+
</a>
|
|
179
|
+
))}
|
|
180
|
+
</nav>
|
|
181
|
+
|
|
182
|
+
<div className="flex items-center gap-1.5">
|
|
183
|
+
<Button asChild variant="ghost" size="icon-sm" className="hidden sm:inline-flex">
|
|
184
|
+
<a href={github.href} target="_blank" rel="noreferrer" aria-label={github.label}>
|
|
185
|
+
{github.icon}
|
|
186
|
+
</a>
|
|
187
|
+
</Button>
|
|
188
|
+
{actions}
|
|
189
|
+
{menu}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</motion.header>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Closing call to action on the atmosphere background. */
|
|
197
|
+
function LandingCta({ title, description, action }: LandingCtaProps) {
|
|
198
|
+
return (
|
|
199
|
+
<div className="landing-atmosphere landing-grain relative overflow-hidden border-t border-separator">
|
|
200
|
+
<section className={`${landingShellClass} relative z-10 py-20 sm:py-28`}>
|
|
201
|
+
<LandingReveal className="flex flex-col items-center text-center">
|
|
202
|
+
<h2 className="max-w-2xl text-balance font-semibold text-3xl leading-[1.1] tracking-[-0.03em] text-foreground sm:text-5xl">
|
|
203
|
+
{title}
|
|
204
|
+
</h2>
|
|
205
|
+
<p className={landingClasses.ctaDescription}>{description}</p>
|
|
206
|
+
<div className="mt-9">{action}</div>
|
|
207
|
+
</LandingReveal>
|
|
208
|
+
</section>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { LandingReveal, LandingRevealItem, LandingHero, LandingNav, LandingCta };
|
|
214
|
+
}
|