Add mobile navigation

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-11-04 19:02:24 +00:00
parent d7d87d5760
commit 7f91058ce4
14 changed files with 652 additions and 220 deletions
+2
View File
@@ -4,6 +4,7 @@ import { Toolbar } from '@/components/Toolbar';
import { Providers } from './providers';
import { CustomTheme } from '@/components/CustomTheme';
import { TableOfContents } from '@/components/TableOfContents';
import { MobileBottomNav } from '@/components/MobileBottomNav';
import styles from './layout.module.css';
import '../css/globals.css';
@@ -62,6 +63,7 @@ export default async function RootLayout({
</div>
</div>
</div>
<MobileBottomNav />
<CustomTheme />
</Providers>
</body>
@@ -0,0 +1,40 @@
.icon {
display: block;
}
.topLine,
.middleLine,
.bottomLine {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Menu state (default) */
.topLine {
transform-origin: 10px 5px;
transform: rotate(0deg);
}
.middleLine {
opacity: 1;
transform-origin: 10px 10px;
}
.bottomLine {
transform-origin: 10px 15px;
transform: rotate(0deg);
}
/* Close state (when open) */
.icon[data-open='true'] .topLine {
transform-origin: 10px 5px;
transform: translateY(5px) rotate(45deg);
}
.icon[data-open='true'] .middleLine {
opacity: 0;
}
.icon[data-open='true'] .bottomLine {
transform-origin: 10px 15px;
transform: translateY(-5px) rotate(-45deg);
}
@@ -0,0 +1,56 @@
import styles from './AnimatedMenuIcon.module.css';
interface AnimatedMenuIconProps {
isOpen: boolean;
size?: number;
}
export const AnimatedMenuIcon = ({
isOpen,
size = 20,
}: AnimatedMenuIconProps) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={styles.icon}
data-open={isOpen}
>
<g className={styles.lines}>
<line
x1="3"
y1="5"
x2="17"
y2="5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className={styles.topLine}
/>
<line
x1="3"
y1="10"
x2="17"
y2="10"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className={styles.middleLine}
/>
<line
x1="3"
y1="15"
x2="17"
y2="15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className={styles.bottomLine}
/>
</g>
</svg>
);
};
@@ -0,0 +1,205 @@
/* Mobile Bottom Navigation Island */
.mobileBottomNav {
display: block;
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
z-index: 300;
pointer-events: none;
@media (min-width: 768px) {
display: none;
}
}
.island {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 0 auto;
width: 50vw;
padding: 8px 12px 8px 12px;
border-radius: 32px;
pointer-events: auto;
background-color: #000;
}
:global([data-theme-mode='dark']) .island {
background-color: #fff;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
.menuButton {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 50%;
transition: background-color 0.2s ease-in-out;
/* White icons on light mode (black island) */
color: white;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
:global([data-theme-mode='dark']) .menuButton {
/* Black icons on dark mode (white island) */
color: black;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
}
.buttonGroup {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
border-radius: 24px;
background-color: rgba(255, 255, 255, 0.2);
}
:global([data-theme-mode='dark']) .buttonGroup {
background-color: rgba(0, 0, 0, 0.08);
}
.buttonGroup button {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
padding: 6px 12px;
border-radius: 20px;
transition: background-color 0.2s ease-in-out;
/* White icons on light mode */
color: white;
&[data-selected] {
background-color: rgba(255, 255, 255, 0.2);
}
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
}
:global([data-theme-mode='dark']) .buttonGroup button {
/* Black icons on dark mode */
color: black;
&[data-selected] {
background-color: rgba(0, 0, 0, 0.2);
}
&:hover {
background-color: rgba(0, 0, 0, 0.15);
}
}
/* Mobile Menu Bottom Sheet */
.overlay {
position: fixed;
inset: 0;
z-index: 200;
background-color: rgba(38, 38, 38, 0.7);
display: flex;
align-items: flex-end;
justify-content: center;
&[data-entering] {
animation: overlayFadeIn 200ms ease-out;
}
&[data-exiting] {
animation: overlayFadeOut 200ms ease-in;
}
}
@keyframes overlayFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes overlayFadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.modal {
width: calc(100% - 48px);
max-height: 50vh;
background-color: var(--bg);
border-radius: 16px;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
outline: none;
margin-bottom: 96px;
&[data-entering] {
animation: slideUp 300ms cubic-bezier(0.32, 0.72, 0, 1);
}
&[data-exiting] {
animation: slideDown 200ms cubic-bezier(0.32, 0.72, 0, 1);
}
}
@keyframes slideUp {
from {
transform: translateY(8%);
}
to {
transform: translateY(0);
}
}
@keyframes slideDown {
from {
transform: translateY(0);
}
to {
transform: translateY(8%);
}
}
.dialog {
display: flex;
flex-direction: column;
height: 100%;
max-height: 70vh;
outline: none;
overflow: hidden;
}
.menuContent {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
display: flex;
flex-direction: column;
gap: 2px;
min-height: 0;
}
@@ -0,0 +1,44 @@
'use client';
import { useState } from 'react';
import { RiMoonLine, RiSunLine } from '@remixicon/react';
import { Button, ToggleButton, ToggleButtonGroup } from 'react-aria-components';
import styles from './MobileBottomNav.module.css';
import { usePlayground } from '@/utils/playground-context';
import { MobileMenu } from './MobileMenu';
import { AnimatedMenuIcon } from './AnimatedMenuIcon';
export const MobileBottomNav = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { selectedTheme, setSelectedTheme } = usePlayground();
return (
<>
<div className={styles.mobileBottomNav}>
<div className={styles.island}>
<Button
className={styles.menuButton}
onPress={() => setIsMenuOpen(!isMenuOpen)}
aria-label={isMenuOpen ? 'Close menu' : 'Open menu'}
>
<AnimatedMenuIcon isOpen={isMenuOpen} size={20} />
</Button>
<ToggleButtonGroup
selectedKeys={selectedTheme}
onSelectionChange={setSelectedTheme}
disallowEmptySelection
className={styles.buttonGroup}
>
<ToggleButton id="light" aria-label="Light mode">
<RiSunLine size={20} />
</ToggleButton>
<ToggleButton id="dark" aria-label="Dark mode">
<RiMoonLine size={20} />
</ToggleButton>
</ToggleButtonGroup>
</div>
</div>
<MobileMenu isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} />
</>
);
};
@@ -0,0 +1,32 @@
'use client';
import { Button, Dialog, Modal, ModalOverlay } from 'react-aria-components';
import { RiCloseLine } from '@remixicon/react';
import styles from './MobileBottomNav.module.css';
import { Navigation } from '@/components/Navigation';
interface MobileMenuProps {
isOpen: boolean;
onClose: () => void;
}
export const MobileMenu = ({ isOpen, onClose }: MobileMenuProps) => {
return (
<ModalOverlay
isOpen={isOpen}
onOpenChange={onClose}
className={styles.overlay}
isDismissable
>
<Modal className={styles.modal}>
<Dialog className={styles.dialog} aria-label="Navigation menu">
{({ close }) => (
<div className={styles.menuContent}>
<Navigation onLinkClick={close} />
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
};
@@ -0,0 +1 @@
export { MobileBottomNav } from './MobileBottomNav';
@@ -0,0 +1,100 @@
.topNav {
& ul {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
}
& li {
margin: 0;
padding: 0;
list-style: none;
}
& li div,
& li a {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
color: var(--primary);
text-decoration: none;
&:hover {
background-color: var(--action);
&[data-disabled='true'] {
background-color: transparent;
}
}
&[data-active='true'] {
background-color: var(--action);
&[data-disabled='true'] {
background-color: transparent;
}
}
&[data-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.sectionTitle {
font-size: 0.6875rem;
font-weight: 500;
padding: 0 12px 4px;
color: var(--secondary);
margin-top: 40px;
text-transform: uppercase;
&:first-child {
margin-top: 12px;
}
}
.line {
text-decoration: none;
align-items: center;
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
height: 28px;
padding: 0 12px;
border-radius: 4px;
color: var(--primary);
flex-shrink: 0;
&:hover {
background-color: var(--action);
}
&.active {
background-color: var(--action);
}
&.active .lineTitle {
color: var(--primary);
}
}
.lineTitle {
font-size: 14px;
font-weight: 400;
color: var(--primary);
}
.lineStatus {
font-size: 14px;
color: var(--secondary);
}
@@ -0,0 +1,125 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Fragment } from 'react';
import clsx from 'clsx';
import {
RiCollageLine,
RiFileHistoryLine,
RiHazeLine,
RiPaletteLine,
RiServiceLine,
RiStackLine,
} from '@remixicon/react';
import { components, layoutComponents } from '@/utils/data';
import styles from './Navigation.module.css';
interface NavigationProps {
onLinkClick?: () => void;
}
const data = [
{
title: 'Layout Components',
content: layoutComponents,
url: '/components',
},
{
title: 'Components',
content: components,
url: '/components',
},
];
export const Navigation = ({ onLinkClick }: NavigationProps) => {
const pathname = usePathname();
return (
<>
<nav className={styles.topNav}>
<ul>
<li>
<Link href="/" data-active={pathname === '/'} onClick={onLinkClick}>
<RiHazeLine size={20} />
Get Started
</Link>
</li>
<li>
<Link
href="/tokens"
data-active={pathname === '/tokens'}
onClick={onLinkClick}
>
<RiPaletteLine size={20} />
Tokens
</Link>
</li>
<li>
<Link
href="/components"
data-active={pathname.startsWith('/components')}
onClick={onLinkClick}
>
<RiCollageLine size={20} />
Components
</Link>
</li>
<li>
<div data-disabled={true}>
<RiStackLine size={20} />
Recipes (Soon)
</div>
</li>
<li>
<div data-disabled={true}>
<RiServiceLine size={20} />
Guides (Soon)
</div>
</li>
<li>
<Link
href="/changelog"
data-active={pathname === '/changelog'}
onClick={onLinkClick}
>
<RiFileHistoryLine size={20} />
Changelog
</Link>
</li>
</ul>
</nav>
{data.map(section => {
return (
<Fragment key={section.title}>
<div className={styles.sectionTitle}>{section.title}</div>
{section.content.map(item => {
const isActive = pathname === `${section.url}/${item.slug}`;
return (
<Link
href={`${section.url}/${item.slug}`}
key={item.slug}
className={clsx(styles.line, {
[styles.active]: isActive,
})}
onClick={onLinkClick}
>
<div className={styles.lineTitle}>{item.title}</div>
<div className={styles.lineStatus}>
{item.status === 'alpha' && 'Alpha'}
{item.status === 'beta' && 'Beta'}
{item.status === 'inProgress' && 'In Progress'}
{item.status === 'stable' && 'Stable'}
{item.status === 'deprecated' && 'Deprecated'}
</div>
</Link>
);
})}
</Fragment>
);
})}
</>
);
};
@@ -0,0 +1 @@
export { Navigation } from './Navigation';
@@ -73,100 +73,3 @@
gap: 2px;
position: relative;
}
.topNav {
& ul {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
}
& li {
margin: 0;
padding: 0;
list-style: none;
}
& li div,
& li a {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: var(--action);
&[data-disabled='true'] {
background-color: transparent;
}
}
&[data-active='true'] {
background-color: var(--action);
&[data-disabled='true'] {
background-color: transparent;
}
}
&[data-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.sectionTitle {
font-size: 0.6875rem;
font-weight: 500;
padding: 0 12px 4px;
color: var(--secondary);
margin-top: 40px;
text-transform: uppercase;
&:first-child {
margin-top: 12px;
}
}
.line {
text-decoration: none;
align-items: center;
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
height: 26px;
padding: 0 12px;
border-radius: 4px;
&:hover {
background-color: var(--action);
}
&.active {
background-color: var(--action);
}
&.active .lineTitle {
color: var(--primary);
}
}
.lineTitle {
font-size: 14px;
font-weight: 400;
color: var(--primary);
}
.lineStatus {
font-size: 14px;
color: var(--secondary);
}
+2 -105
View File
@@ -1,38 +1,11 @@
'use client';
import styles from './Sidebar.module.css';
import { components, layoutComponents } from '@/utils/data';
import { ScrollArea } from '@base-ui-components/react/scroll-area';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Fragment } from 'react';
import clsx from 'clsx';
import {
RiCollageLine,
RiFileHistoryLine,
RiHazeLine,
RiPaletteLine,
RiServiceLine,
RiStackLine,
} from '@remixicon/react';
import { Logo } from './Logo';
const data = [
{
title: 'Layout Components',
content: layoutComponents,
url: '/components',
},
{
title: 'Components',
content: components,
url: '/components',
},
];
import { Navigation } from '@/components/Navigation';
export const Sidebar = () => {
const pathname = usePathname();
return (
<div className={styles.sidebar}>
<div className={styles.logoContainer}>
@@ -42,83 +15,7 @@ export const Sidebar = () => {
<ScrollArea.Viewport className={styles.viewport}>
<div className={styles.content}>
<div className={styles.menu}>
<nav className={styles.topNav}>
<ul>
<li>
<Link href="/" data-active={pathname === '/'}>
<RiHazeLine size={20} />
Get Started
</Link>
</li>
<li>
<Link href="/tokens" data-active={pathname === '/tokens'}>
<RiPaletteLine size={20} />
Tokens
</Link>
</li>
<li>
<Link
href="/components"
data-active={pathname.startsWith('/components')}
>
<RiCollageLine size={20} />
Components
</Link>
</li>
<li>
<div data-disabled={true}>
<RiStackLine size={20} />
Recipes (Soon)
</div>
</li>
<li>
<div data-disabled={true}>
<RiServiceLine size={20} />
Guides (Soon)
</div>
</li>
<li>
<Link
href="/changelog"
data-active={pathname === '/changelog'}
>
<RiFileHistoryLine size={20} />
Changelog
</Link>
</li>
</ul>
</nav>
{data.map(section => {
return (
<Fragment key={section.title}>
<div className={styles.sectionTitle}>{section.title}</div>
{section.content.map(item => {
const isActive =
pathname === `${section.url}/${item.slug}`;
return (
<Link
href={`${section.url}/${item.slug}`}
key={item.slug}
className={clsx(styles.line, {
[styles.active]: isActive,
})}
>
<div className={styles.lineTitle}>{item.title}</div>
<div className={styles.lineStatus}>
{item.status === 'alpha' && 'Alpha'}
{item.status === 'beta' && 'Beta'}
{item.status === 'inProgress' && 'In Progress'}
{item.status === 'stable' && 'Stable'}
{item.status === 'deprecated' && 'Deprecated'}
</div>
</Link>
);
})}
</Fragment>
);
})}
<Navigation />
</div>
</div>
</ScrollArea.Viewport>
@@ -18,6 +18,24 @@
font-weight: 500;
}
.logoMobile {
display: block;
@media (min-width: 768px) {
display: none;
}
}
.breadcrumbDesktop {
display: none;
@media (min-width: 768px) {
display: flex;
align-items: center;
gap: 0.5rem;
}
}
.breadcrumbLink {
color: var(--secondary);
text-decoration: none;
@@ -70,7 +88,7 @@
align-items: center;
justify-content: center;
background-color: var(--bg);
border: 1px solid var(--border2);
border: 1px solid var(--border);
border-radius: 32px;
padding-inline: 16px;
color: var(--primary);
@@ -105,7 +123,7 @@
display: flex;
align-items: center;
gap: 4px;
border: 1px solid var(--border2);
border: 1px solid var(--border);
border-radius: 32px;
padding-inline: 4px;
height: 32px;
+24 -16
View File
@@ -22,6 +22,7 @@ import { usePlayground } from '@/utils/playground-context';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { components, layoutComponents } from '@/utils/data';
import { Logo } from '@/components/Sidebar/Logo';
interface ToolbarProps {
version: string;
@@ -86,23 +87,30 @@ export const Toolbar = ({ version }: ToolbarProps) => {
return (
<div className={styles.toolbar}>
<div className={styles.breadcrumb}>
{breadcrumb.section && breadcrumb.sectionLink ? (
<>
<Link
href={breadcrumb.sectionLink}
className={styles.breadcrumbLink}
>
{breadcrumb.section}
</Link>
<RiArrowRightSLine
size={16}
className={styles.breadcrumbSeparator}
/>
<div className={styles.logoMobile}>
<Logo />
</div>
<div className={styles.breadcrumbDesktop}>
{breadcrumb.section && breadcrumb.sectionLink ? (
<>
<Link
href={breadcrumb.sectionLink}
className={styles.breadcrumbLink}
>
{breadcrumb.section}
</Link>
<RiArrowRightSLine
size={16}
className={styles.breadcrumbSeparator}
/>
<span className={styles.breadcrumbCurrent}>
{breadcrumb.title}
</span>
</>
) : (
<span className={styles.breadcrumbCurrent}>{breadcrumb.title}</span>
</>
) : (
<span className={styles.breadcrumbCurrent}>{breadcrumb.title}</span>
)}
)}
</div>
</div>
<div className={styles.actions}>
<Select