Skip to content

Commit ccd20d6

Browse files
committed
feat: animation speed control, sticky tabs example, and professional UI refactor
- Add 'speed' prop to Header and Footer (fast, normal, slow). - Implement 'Sticky Tabs' example with secondary navigation hitching. - Refactor examples homepage to a serious, shadcn-like registry design. - Update component themes to use standard CSS variables (bg-background, border-border). - Fix 'Static Header' example and ensure compatibility with safeArea. - Clean up Vitest configuration.
1 parent a5c004d commit ccd20d6

33 files changed

Lines changed: 1104 additions & 283 deletions

apps/examples/app/page.tsx

Lines changed: 122 additions & 262 deletions
Large diffs are not rendered by default.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { AppShell, Header, Content, HeaderNav, HeaderNavItem, MotionProvider, cn } from "@appshell/react";
5+
import { framerMotionAdapter } from "@appshell/react/motion-framer";
6+
import { Search, Bell, User, Clock, ArrowRight, TrendingUp, Bookmark, LayoutGrid, List, Info } from "lucide-react";
7+
8+
export default function StickyTabsPage() {
9+
const [activeTab, setActiveTab] = useState("overview");
10+
11+
return (
12+
<MotionProvider adapter={framerMotionAdapter}>
13+
<AppShell safeArea>
14+
<Header
15+
behavior="fixed"
16+
theme="light"
17+
logo={
18+
<div className="flex items-center gap-2">
19+
<div className="size-8 rounded-lg bg-primary flex items-center justify-center">
20+
<LayoutGrid className="size-5 text-primary-foreground" />
21+
</div>
22+
<span className="text-lg font-bold tracking-tight">Console</span>
23+
</div>
24+
}
25+
nav={
26+
<HeaderNav>
27+
<HeaderNavItem label="Dashboard" active />
28+
<HeaderNavItem label="Resources" />
29+
<HeaderNavItem label="Activity" />
30+
</HeaderNav>
31+
}
32+
actions={
33+
<div className="flex items-center gap-1">
34+
<button
35+
type="button"
36+
className="rounded-lg p-2 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
37+
>
38+
<Search className="size-5" />
39+
</button>
40+
<button
41+
type="button"
42+
className="rounded-lg p-2 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
43+
>
44+
<Bell className="size-5" />
45+
</button>
46+
<div className="size-8 rounded-full bg-accent ml-2 flex items-center justify-center">
47+
<User className="size-5 text-muted-foreground" />
48+
</div>
49+
</div>
50+
}
51+
title="Project Infrastructure"
52+
subtitle="Manage your cloud resources and deployment pipelines"
53+
/>
54+
55+
<Content className="pb-20">
56+
{/* Hero Section */}
57+
<div className="w-full h-48 bg-gradient-to-r from-blue-600 to-indigo-700 relative overflow-hidden">
58+
<div className="absolute inset-0 opacity-10">
59+
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-white via-transparent to-transparent scale-150" />
60+
</div>
61+
<div className="absolute inset-0 flex items-center justify-center text-white/20">
62+
<LayoutGrid className="size-32" />
63+
</div>
64+
</div>
65+
66+
{/* Sticky Tabs Bar */}
67+
<div className="sticky top-[112px] z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4 sm:px-6">
68+
<div className="mx-auto max-w-7xl">
69+
<div className="flex h-12 items-center gap-6 overflow-x-auto no-scrollbar">
70+
{[
71+
{ id: "overview", label: "Overview", icon: Info },
72+
{ id: "resources", label: "Resources", icon: LayoutGrid },
73+
{ id: "logs", label: "Logs", icon: List },
74+
{ id: "settings", label: "Settings", icon: Bell },
75+
].map((tab) => (
76+
<button
77+
key={tab.id}
78+
onClick={() => setActiveTab(tab.id)}
79+
className={cn(
80+
"inline-flex items-center gap-2 border-b-2 px-1 pb-3 pt-4 text-sm font-medium transition-all hover:text-foreground",
81+
activeTab === tab.id
82+
? "border-primary text-foreground"
83+
: "border-transparent text-muted-foreground hover:border-border"
84+
)}
85+
>
86+
<tab.icon className="size-4" />
87+
{tab.label}
88+
</button>
89+
))}
90+
</div>
91+
</div>
92+
</div>
93+
94+
{/* Page Content */}
95+
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8">
96+
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
97+
{Array.from({ length: 12 }).map((_, i) => (
98+
<div
99+
key={i}
100+
className="group rounded-xl border bg-card p-6 text-card-foreground shadow-sm transition-all hover:shadow-md"
101+
>
102+
<div className="mb-4 flex items-center justify-between">
103+
<div className="size-10 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-600">
104+
<LayoutGrid className="size-6" />
105+
</div>
106+
<div className="flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-xs font-medium text-emerald-600">
107+
<span className="size-1.5 rounded-full bg-emerald-500" />
108+
Running
109+
</div>
110+
</div>
111+
<h3 className="text-lg font-semibold leading-none tracking-tight">
112+
Resource Instance {i + 1}
113+
</h3>
114+
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">
115+
Standard high-performance instance deployed in Region us-east-1.
116+
Optimized for compute-intensive workloads.
117+
</p>
118+
<div className="mt-6 flex items-center justify-between pt-4 border-t">
119+
<div className="text-xs text-muted-foreground font-mono">
120+
i-0a8f92b{i}
121+
</div>
122+
<button className="text-xs font-medium text-primary hover:underline flex items-center gap-1">
123+
Manage <ArrowRight className="size-3" />
124+
</button>
125+
</div>
126+
</div>
127+
))}
128+
</div>
129+
</div>
130+
</Content>
131+
132+
{/* Behavior indicator */}
133+
<div className="fixed bottom-4 left-4 z-50 flex flex-col gap-2 rounded-2xl bg-zinc-950/90 p-3 text-white/90 shadow-2xl backdrop-blur-md border border-white/10 sm:flex-row sm:items-center sm:rounded-full sm:px-4 sm:py-2">
134+
<div className="flex items-center gap-2 text-[11px] font-mono whitespace-nowrap border-b border-white/10 pb-2 sm:border-b-0 sm:pb-0 sm:pr-3 sm:border-r sm:mr-1">
135+
<span className="size-2 rounded-full bg-blue-500 animate-pulse" />
136+
HEADER: behavior=&quot;fixed&quot;
137+
</div>
138+
<div className="flex items-center gap-2 text-[11px] font-mono whitespace-nowrap">
139+
<span className="size-2 rounded-full bg-emerald-500" />
140+
TABS: sticky top-[112px]
141+
</div>
142+
</div>
143+
</AppShell>
144+
</MotionProvider>
145+
);
146+
}

packages/react/__tests__/Header.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,13 @@ describe("Header", () => {
4242
it("applies light theme by default", () => {
4343
const { container } = renderHeader();
4444
const header = container.querySelector("header");
45-
expect(header?.className).toContain("bg-white");
45+
expect(header?.className).toContain("bg-background");
4646
});
4747

4848
it("applies dark theme", () => {
4949
const { container } = renderHeader({ theme: "dark" });
5050
const header = container.querySelector("header");
51-
expect(header?.className).toContain("bg-gray-900");
51+
expect(header?.className).toContain("bg-zinc-950");
5252
});
5353

5454
it("renders mobile menu toggle", () => {

packages/react/src/Footer.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import { memo } from "react";
44
import { cn } from "./cn";
55
import { useMotion } from "./motion";
66
import { useScrollDirection } from "./hooks/use-scroll-direction";
7-
import type { FooterProps, FooterItemProps } from "./types";
7+
import type { FooterProps, FooterItemProps, AnimationSpeed } from "./types";
8+
9+
const speedMap: Record<AnimationSpeed, number> = {
10+
fast: 0.15,
11+
normal: 0.3,
12+
slow: 0.6,
13+
};
814

915
export const FooterItem = memo(function FooterItem({
1016
icon,
@@ -51,12 +57,14 @@ export const Footer = memo(function Footer({
5157
variant = "tab-bar",
5258
behavior = "static",
5359
position = "center",
60+
speed = "normal",
5461
className,
5562
children,
5663
}: FooterProps) {
5764
const { motion, AnimatePresence } = useMotion();
5865
const scrollDirection = useScrollDirection();
5966
const shouldHide = behavior === "auto-hide" && scrollDirection === "down";
67+
const duration = speedMap[speed];
6068

6169
if (variant === "floating") {
6270
const positionClass = {
@@ -81,7 +89,7 @@ export const Footer = memo(function Footer({
8189
initial={{ y: 20, opacity: 0 }}
8290
animate={{ y: 0, opacity: 1 }}
8391
exit={{ y: 20, opacity: 0 }}
84-
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
92+
transition={{ duration: duration, ease: [0.16, 1, 0.3, 1] }}
8593
className="pointer-events-auto"
8694
>
8795
{children}
@@ -100,7 +108,7 @@ export const Footer = memo(function Footer({
100108
initial={{ y: 48 }}
101109
animate={{ y: 0 }}
102110
exit={{ y: 48 }}
103-
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
111+
transition={{ duration: duration, ease: [0.16, 1, 0.3, 1] }}
104112
className={cn(
105113
"fixed bottom-0 left-0 right-0 z-50 h-12 border-t bg-background/95 backdrop-blur-xl",
106114
className
@@ -124,7 +132,7 @@ export const Footer = memo(function Footer({
124132
initial={{ y: 80 }}
125133
animate={{ y: 0 }}
126134
exit={{ y: 80 }}
127-
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
135+
transition={{ duration: duration, ease: [0.16, 1, 0.3, 1] }}
128136
className={cn(
129137
"fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur-xl",
130138
className

packages/react/src/Header.tsx

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,16 @@ import {
1010
import { cn } from "./cn";
1111
import { useMotion } from "./motion";
1212
import { useScrollDirection } from "./hooks/use-scroll-direction";
13-
import type { HeaderProps } from "./types";
13+
import type { HeaderProps, AnimationSpeed } from "./types";
1414
import { HeaderProvider } from "./HeaderContext";
1515

1616
const themeStyles = {
1717
light: {
18-
wrapper: "bg-white text-gray-900",
19-
nav: "bg-white/95 border-gray-200",
20-
context: "bg-white/95 border-gray-200",
21-
search: "bg-white/95 border-gray-200",
22-
mobile: "bg-white text-gray-900 border-gray-200",
18+
wrapper: "bg-background text-foreground",
19+
nav: "bg-background/95 border-border",
20+
context: "bg-background/95 border-border",
21+
search: "bg-background/95 border-border",
22+
mobile: "bg-background text-foreground border-border",
2323
},
2424
primary: {
2525
wrapper: "bg-primary text-primary-foreground",
@@ -29,14 +29,27 @@ const themeStyles = {
2929
mobile: "bg-primary text-primary-foreground border-primary/80",
3030
},
3131
dark: {
32-
wrapper: "bg-gray-900 text-white",
33-
nav: "bg-gray-900 border-gray-800",
34-
context: "bg-gray-900 border-gray-800",
35-
search: "bg-gray-900 border-gray-800",
36-
mobile: "bg-gray-900 text-white border-gray-800",
32+
wrapper: "bg-zinc-950 text-slate-50",
33+
nav: "bg-zinc-950 border-zinc-800",
34+
context: "bg-zinc-950 border-zinc-800",
35+
search: "bg-zinc-950 border-zinc-800",
36+
mobile: "bg-zinc-950 text-slate-50 border-zinc-800",
37+
},
38+
none: {
39+
wrapper: "",
40+
nav: "",
41+
context: "",
42+
search: "",
43+
mobile: "",
3744
},
3845
} as const;
3946

47+
const speedMap: Record<AnimationSpeed, number> = {
48+
fast: 0.15,
49+
normal: 0.3,
50+
slow: 0.6,
51+
};
52+
4053
export const Header = memo(function Header({
4154
logo,
4255
actions,
@@ -46,6 +59,7 @@ export const Header = memo(function Header({
4659
searchContent,
4760
theme = "light",
4861
behavior = "fixed",
62+
speed = "normal",
4963
mobileMenu,
5064
onVisibilityChange,
5165
className,
@@ -54,6 +68,7 @@ export const Header = memo(function Header({
5468
const scrollDirection = useScrollDirection();
5569
const t = themeStyles[theme];
5670
const [mobileOpen, setMobileOpen] = useState(false);
71+
const duration = speedMap[speed];
5772

5873
const ghostRef = useRef<HTMLElement>(null);
5974
const [threshold, setThreshold] = useState(0);
@@ -149,7 +164,7 @@ export const Header = memo(function Header({
149164
{mobileMenu && (
150165
<button
151166
type="button"
152-
className="p-1 rounded-md hover:bg-black/5 md:hidden transition-colors"
167+
className="p-1 rounded-md hover:bg-accent/50 md:hidden transition-colors"
153168
onClick={toggleMobile}
154169
aria-label={mobileOpen ? "Close menu" : "Open menu"}
155170
>
@@ -207,7 +222,7 @@ export const Header = memo(function Header({
207222
initial={{ height: 0, opacity: 0 }}
208223
animate={{ height: "auto", opacity: 1 }}
209224
exit={{ height: 0, opacity: 0 }}
210-
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
225+
transition={{ duration: duration, ease: [0.16, 1, 0.3, 1] }}
211226
className={cn(
212227
"md:hidden overflow-hidden border-t w-full",
213228
t.mobile
@@ -269,7 +284,7 @@ export const Header = memo(function Header({
269284
initial={{ y: -8, opacity: 0 }}
270285
animate={{ y: 0, opacity: 1 }}
271286
exit={{ y: -8, opacity: 0 }}
272-
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
287+
transition={{ duration: duration, ease: [0.16, 1, 0.3, 1] }}
273288
aria-hidden
274289
className={cn(
275290
"fixed top-0 left-0 right-0 z-[60] shadow-lg",

packages/react/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export { Footer, FooterItem } from "./Footer";
44
export { SafeArea } from "./SafeArea";
55
export { Content } from "./Content";
66
export { AppShellProvider, useAppShell } from "./context";
7+
export { HeaderProvider, useHeaderTheme } from "./HeaderContext";
78
export { useScrollDirection } from "./hooks/use-scroll-direction";
89
export { useSafeArea } from "./hooks/use-safe-area";
910
export { MotionProvider } from "./motion";
@@ -17,6 +18,7 @@ export type {
1718
HeaderBehavior,
1819
HeaderTheme,
1920
HeaderProps,
21+
AnimationSpeed,
2022
FooterVariant,
2123
FooterBehavior,
2224
FooterPosition,
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
3+
import { fn } from 'storybook/test';
4+
5+
import { Button } from './Button';
6+
7+
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
8+
const meta = {
9+
title: 'Example/Button',
10+
component: Button,
11+
parameters: {
12+
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
13+
layout: 'centered',
14+
},
15+
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
16+
tags: ['autodocs'],
17+
// More on argTypes: https://storybook.js.org/docs/api/argtypes
18+
argTypes: {
19+
backgroundColor: { control: 'color' },
20+
},
21+
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args
22+
args: { onClick: fn() },
23+
} satisfies Meta<typeof Button>;
24+
25+
export default meta;
26+
type Story = StoryObj<typeof meta>;
27+
28+
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
29+
export const Primary: Story = {
30+
args: {
31+
primary: true,
32+
label: 'Button',
33+
},
34+
};
35+
36+
export const Secondary: Story = {
37+
args: {
38+
label: 'Button',
39+
},
40+
};
41+
42+
export const Large: Story = {
43+
args: {
44+
size: 'large',
45+
label: 'Button',
46+
},
47+
};
48+
49+
export const Small: Story = {
50+
args: {
51+
size: 'small',
52+
label: 'Button',
53+
},
54+
};

0 commit comments

Comments
 (0)