Floating two columns with sidebar
A floating bar whose menus open two columns beside video tutorials, over two buttons.
Last updated
---
/**
* Floating two columns with sidebar: A floating bar whose menus open two columns beside video tutorials, over two buttons.
*
* @block header-navigation
* @order 19
*/
import { ArrowRight, Book, BookOpen, CirclePlay, FileCode, Flag, Folder, MessageCircle, Orbit, Play, Sparkles, Users } from '@lucide/astro'
import Navbar from '@/components/apogee/Navbar/Navbar.astro'
import NavLink from '@/components/apogee/NavLink/NavLink.astro'
import NavDropdown from '@/components/apogee/NavDropdown/NavDropdown.astro'
import Link from '@/components/apogee/Link/Link.astro'
import Button from '@/components/apogee/Button/Button.astro'
import FeaturedIcon from '@/components/apogee/FeaturedIcon/FeaturedIcon.astro'
import Badge from '@/components/apogee/Badge/Badge.astro'
import Image from '@/components/apogee/Image/Image.astro'
import type { Action } from '@/components/apogee/sections/_shared/spine.ts'
import type { NavGroup, NavigationContent } from '@/components/apogee/sections/_shared/navigation.ts'
// One icon set per column, cycling by position.
const icons = [
[Book, Sparkles, CirclePlay, FileCode],
[Flag, MessageCircle, Users, Folder],
]
// A menu's last group is the video tutorials beside the columns.
const groups: NavGroup[] = [
{ heading: 'Resources', items: [
{ label: 'Blog', href: '#', description: 'The latest industry news and guides, written by our team.' },
{ label: 'Customer stories', href: '#', description: 'How teams use the product to grow faster.' },
{ label: 'Video tutorials', href: '#', description: 'Get up and running on new features with short guides.' },
{ label: 'Documentation', href: '#', description: 'In-depth articles on our tools and technologies.' },
] },
{ heading: 'Company', items: [
{ label: 'About us', href: '#', description: 'Our team and what we are working towards.' },
{ label: 'Press', href: '#', description: 'News, press releases and press resources.' },
{ label: 'Careers', href: '#', description: 'We are always looking for talented people. Join our remote team.', badge: "We're hiring" },
{ label: 'Legal', href: '#', description: 'Company details, licensing, terms and privacy.' },
] },
{ heading: 'Tutorials', items: [
{ label: 'How to get started', href: '#', description: 'An overview of the basics, so you can start building.', media: { src: 'https://picsum.photos/seed/apogee-nav-video-1/480/270', alt: '' } },
{ label: 'Advanced features', href: '#', description: 'Advanced analytics, features and shortcuts, once you are ready.', media: { src: 'https://picsum.photos/seed/apogee-nav-video-2/480/270', alt: '' } },
] },
]
/** @composable */
interface Props extends NavigationContent {
/** The bar's fill: the page, a grey band or the brand colour. */
background?: 'page' | 'neutral-faded' | 'primary' | undefined
/** `dark` sets the bar in the dark theme on a light page. */
theme?: 'dark' | undefined
/** The link beside the videos' heading. @category content */
videosAction?: Action | null
/** Text of each video's link. @category content */
videoLinkLabel?: string
/** The solid button under each menu. @category content */
footerPrimaryAction?: Action | null
/** The outline button under each menu. @category content */
footerSecondaryAction?: Action | null
}
const {
brand = 'Example',
links = [
{ label: 'Products', groups },
{ label: 'Services', groups },
{ label: 'Pricing', href: '#' },
{ label: 'Resources', groups },
{ label: 'About', href: '#' },
],
primaryAction = { label: 'Sign up', href: '#' },
secondaryAction = { label: 'Log in', href: '#' },
videosAction = { label: 'All video tutorials', href: '#' },
videoLinkLabel = 'Watch video',
footerPrimaryAction = { label: 'View all posts', href: '#' },
footerSecondaryAction = { label: 'Documentation', href: '#' },
background,
theme,
} = Astro.props
---
<Navbar floating background={background} data-theme={theme}>
<Link slot="brand" href="/" color="inherit" underline="none" class="inline-flex items-center gap-2 text-lg font-semibold"><FeaturedIcon theme="solid" color="primary" size="small"><Orbit /></FeaturedIcon>{brand}</Link>
{links.map((l) => {
if (!('groups' in l)) return <NavLink href={l.href}>{l.label}</NavLink>
const videos = l.groups.at(-1)
return (
<NavDropdown label={l.label} fullWidth floating>
<div class="p-4 md:p-6">
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
<div class="grid gap-4 sm:grid-cols-2">
{l.groups.slice(0, -1).map((g, gi) => {
const set = icons[gi % icons.length]!
return (
<div class="flex flex-col gap-2">
{g.heading && <p class="px-3 text-sm font-semibold text-primary">{g.heading}</p>}
<ul class="list-none p-0 flex flex-col gap-0.5">
{g.items.map((i, n) => {
const Icon = set[n % set.length]!
return (
<li>
<Link href={i.href} color="inherit" underline="none" class="flex gap-3 rounded-lg p-3 transition-colors hover:bg-neutral-faded">
<Icon class="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
<span class="flex flex-col gap-1">
<span class="flex flex-wrap items-center gap-2 font-semibold">{i.label}{i.badge && <Badge size="small" color={i.badge === 'New' ? 'neutral' : 'positive'} variant="faded">{i.badge}</Badge>}</span>
{i.description && <span class="line-clamp-2 text-sm text-neutral-faded">{i.description}</span>}
</span>
</Link>
</li>
)})}
</ul>
</div>
)})}
</div>
{videos && <div class="flex flex-col gap-4 rounded-2xl bg-neutral-faded p-4">
{(videos.heading || videosAction) && <div class="flex items-center justify-between gap-4">{videos.heading && <p class="text-sm font-semibold text-primary">{videos.heading}</p>}{videosAction && <Link href={videosAction.href} class="inline-flex items-center gap-1.5 text-sm font-semibold">{videosAction.label}<ArrowRight class="size-4 rtl:-scale-x-100" aria-hidden="true" /></Link>}</div>}
<ul class="flex list-none flex-col gap-4 p-0 sm:flex-row">
{videos.items.map((v) => (
<li class="flex flex-1 flex-col gap-3">
{v.media && <div class="relative">
<Image src={v.media.src} alt={v.media.alt} width={480} height={270} fit="cover" radius="lg" class="aspect-video w-full" />
<span class="absolute inset-0 grid place-items-center" aria-hidden="true"><span class="grid size-10 place-items-center rounded-full bg-page/90 text-neutral"><Play class="size-4 translate-x-px fill-current" /></span></span>
</div>}
<div class="flex flex-col gap-1">
<p class="font-semibold">{v.label}</p>
{v.description && <p class="line-clamp-2 text-sm text-neutral-faded">{v.description}</p>}
</div>
<Link href={v.href} class="inline-flex items-center gap-1.5 text-sm font-semibold">{videoLinkLabel}<span class="sr-only">: {v.label}</span><CirclePlay class="size-4" aria-hidden="true" /></Link>
</li>
))}
</ul>
</div>}
</div>
</div>
{(footerPrimaryAction || footerSecondaryAction) && <div class="flex flex-col gap-3 border-t border-neutral bg-neutral-faded px-4 py-3 sm:flex-row sm:justify-end md:px-6">
{footerSecondaryAction && <Button href={footerSecondaryAction.href} variant="outline" color="neutral" size="small"><BookOpen slot="start" aria-hidden="true" />{footerSecondaryAction.label}</Button>}
{footerPrimaryAction && <Button href={footerPrimaryAction.href} size="small">{footerPrimaryAction.label}</Button>}
</div>}
</NavDropdown>
)
})}
{secondaryAction && <Button slot="actions" href={secondaryAction.href} variant="outline" color="neutral" size="small">{secondaryAction.label}</Button>}
{primaryAction && <Button slot="actions" href={primaryAction.href} size="small">{primaryAction.label}</Button>}
</Navbar>
Show codeHide code
---
/**
* Floating two columns with sidebar: A floating bar whose menus open two columns beside video tutorials, over two buttons.
*
* @block header-navigation
* @order 19
*/
import { ArrowRight, Book, BookOpen, CirclePlay, FileCode, Flag, Folder, MessageCircle, Orbit, Play, Sparkles, Users } from '@lucide/astro'
import Navbar from '@/components/apogee/Navbar/Navbar.astro'
import NavLink from '@/components/apogee/NavLink/NavLink.astro'
import NavDropdown from '@/components/apogee/NavDropdown/NavDropdown.astro'
import Link from '@/components/apogee/Link/Link.astro'
import Button from '@/components/apogee/Button/Button.astro'
import FeaturedIcon from '@/components/apogee/FeaturedIcon/FeaturedIcon.astro'
import Badge from '@/components/apogee/Badge/Badge.astro'
import Image from '@/components/apogee/Image/Image.astro'
import type { Action } from '@/components/apogee/sections/_shared/spine.ts'
import type { NavGroup, NavigationContent } from '@/components/apogee/sections/_shared/navigation.ts'
// One icon set per column, cycling by position.
const icons = [
[Book, Sparkles, CirclePlay, FileCode],
[Flag, MessageCircle, Users, Folder],
]
// A menu's last group is the video tutorials beside the columns.
const groups: NavGroup[] = [
{ heading: 'Resources', items: [
{ label: 'Blog', href: '#', description: 'The latest industry news and guides, written by our team.' },
{ label: 'Customer stories', href: '#', description: 'How teams use the product to grow faster.' },
{ label: 'Video tutorials', href: '#', description: 'Get up and running on new features with short guides.' },
{ label: 'Documentation', href: '#', description: 'In-depth articles on our tools and technologies.' },
] },
{ heading: 'Company', items: [
{ label: 'About us', href: '#', description: 'Our team and what we are working towards.' },
{ label: 'Press', href: '#', description: 'News, press releases and press resources.' },
{ label: 'Careers', href: '#', description: 'We are always looking for talented people. Join our remote team.', badge: "We're hiring" },
{ label: 'Legal', href: '#', description: 'Company details, licensing, terms and privacy.' },
] },
{ heading: 'Tutorials', items: [
{ label: 'How to get started', href: '#', description: 'An overview of the basics, so you can start building.', media: { src: 'https://picsum.photos/seed/apogee-nav-video-1/480/270', alt: '' } },
{ label: 'Advanced features', href: '#', description: 'Advanced analytics, features and shortcuts, once you are ready.', media: { src: 'https://picsum.photos/seed/apogee-nav-video-2/480/270', alt: '' } },
] },
]
/** @composable */
interface Props extends NavigationContent {
/** The bar's fill: the page, a grey band or the brand colour. */
background?: 'page' | 'neutral-faded' | 'primary' | undefined
/** `dark` sets the bar in the dark theme on a light page. */
theme?: 'dark' | undefined
/** The link beside the videos' heading. @category content */
videosAction?: Action | null
/** Text of each video's link. @category content */
videoLinkLabel?: string
/** The solid button under each menu. @category content */
footerPrimaryAction?: Action | null
/** The outline button under each menu. @category content */
footerSecondaryAction?: Action | null
}
const {
brand = 'Example',
links = [
{ label: 'Products', groups },
{ label: 'Services', groups },
{ label: 'Pricing', href: '#' },
{ label: 'Resources', groups },
{ label: 'About', href: '#' },
],
primaryAction = { label: 'Sign up', href: '#' },
secondaryAction = { label: 'Log in', href: '#' },
videosAction = { label: 'All video tutorials', href: '#' },
videoLinkLabel = 'Watch video',
footerPrimaryAction = { label: 'View all posts', href: '#' },
footerSecondaryAction = { label: 'Documentation', href: '#' },
background,
theme,
} = Astro.props
---
<Navbar floating background={background} data-theme={theme}>
<Link slot="brand" href="/" color="inherit" underline="none" class="inline-flex items-center gap-2 text-lg font-semibold"><FeaturedIcon theme="solid" color="primary" size="small"><Orbit /></FeaturedIcon>{brand}</Link>
{links.map((l) => {
if (!('groups' in l)) return <NavLink href={l.href}>{l.label}</NavLink>
const videos = l.groups.at(-1)
return (
<NavDropdown label={l.label} fullWidth floating>
<div class="p-4 md:p-6">
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
<div class="grid gap-4 sm:grid-cols-2">
{l.groups.slice(0, -1).map((g, gi) => {
const set = icons[gi % icons.length]!
return (
<div class="flex flex-col gap-2">
{g.heading && <p class="px-3 text-sm font-semibold text-primary">{g.heading}</p>}
<ul class="list-none p-0 flex flex-col gap-0.5">
{g.items.map((i, n) => {
const Icon = set[n % set.length]!
return (
<li>
<Link href={i.href} color="inherit" underline="none" class="flex gap-3 rounded-lg p-3 transition-colors hover:bg-neutral-faded">
<Icon class="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
<span class="flex flex-col gap-1">
<span class="flex flex-wrap items-center gap-2 font-semibold">{i.label}{i.badge && <Badge size="small" color={i.badge === 'New' ? 'neutral' : 'positive'} variant="faded">{i.badge}</Badge>}</span>
{i.description && <span class="line-clamp-2 text-sm text-neutral-faded">{i.description}</span>}
</span>
</Link>
</li>
)})}
</ul>
</div>
)})}
</div>
{videos && <div class="flex flex-col gap-4 rounded-2xl bg-neutral-faded p-4">
{(videos.heading || videosAction) && <div class="flex items-center justify-between gap-4">{videos.heading && <p class="text-sm font-semibold text-primary">{videos.heading}</p>}{videosAction && <Link href={videosAction.href} class="inline-flex items-center gap-1.5 text-sm font-semibold">{videosAction.label}<ArrowRight class="size-4 rtl:-scale-x-100" aria-hidden="true" /></Link>}</div>}
<ul class="flex list-none flex-col gap-4 p-0 sm:flex-row">
{videos.items.map((v) => (
<li class="flex flex-1 flex-col gap-3">
{v.media && <div class="relative">
<Image src={v.media.src} alt={v.media.alt} width={480} height={270} fit="cover" radius="lg" class="aspect-video w-full" />
<span class="absolute inset-0 grid place-items-center" aria-hidden="true"><span class="grid size-10 place-items-center rounded-full bg-page/90 text-neutral"><Play class="size-4 translate-x-px fill-current" /></span></span>
</div>}
<div class="flex flex-col gap-1">
<p class="font-semibold">{v.label}</p>
{v.description && <p class="line-clamp-2 text-sm text-neutral-faded">{v.description}</p>}
</div>
<Link href={v.href} class="inline-flex items-center gap-1.5 text-sm font-semibold">{videoLinkLabel}<span class="sr-only">: {v.label}</span><CirclePlay class="size-4" aria-hidden="true" /></Link>
</li>
))}
</ul>
</div>}
</div>
</div>
{(footerPrimaryAction || footerSecondaryAction) && <div class="flex flex-col gap-3 border-t border-neutral bg-neutral-faded px-4 py-3 sm:flex-row sm:justify-end md:px-6">
{footerSecondaryAction && <Button href={footerSecondaryAction.href} variant="outline" color="neutral" size="small"><BookOpen slot="start" aria-hidden="true" />{footerSecondaryAction.label}</Button>}
{footerPrimaryAction && <Button href={footerPrimaryAction.href} size="small">{footerPrimaryAction.label}</Button>}
</div>}
</NavDropdown>
)
})}
{secondaryAction && <Button slot="actions" href={secondaryAction.href} variant="outline" color="neutral" size="small">{secondaryAction.label}</Button>}
{primaryAction && <Button slot="actions" href={primaryAction.href} size="small">{primaryAction.label}</Button>}
</Navbar>Add
npx @ava-ui/apogee add header-navigation/floating-two-col-with-sidebar
This copies the block to src/components/apogee/blocks/header-navigation/ with everything it uses: Badge, Button, FeaturedIcon, Image, Link, NavDropdown, NavLink, Navbar. Every piece of copy is a prop, with the sample copy as its default. Pass yours, or null to hide a part; see copy is props.
Accessibility
- Each menu is a button with a native popover: Esc or a click outside closes it and focus returns to the button. On phones the menus are rows that expand inside the menu. No JS is needed for either.
- Photos that only decorate have an empty
alt; give yours one when it carries meaning.
Related
NavbarA responsive site header that collapses its links into a mobile panel below the md breakpoint.
NavDropdownA Navbar item that opens a panel of links; a popover under the button or across the header on desktop, a row that expands in the phone menu.
NavLinkA navigation link that auto-detects and styles the current route, with prefix matching, external links and a collapsible NavGroup.