Ornament
Vertical tab bar with custom scrollbar
This navigation menu expands when focused. Use tab key to navigate between tabs.
'use client'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@radix-ui/react-tabs'
import { AnimatePresence, motion } from 'motion/react'
import React, { createContext, useContext, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '../core/button'
import { Text } from '../ui/typography'
import { Window, type WindowProps } from './window'
const CONSTANTS = {
EXPANDED_WIDTH: '100%',
COLLAPSED_WIDTH: 44,
ENTERANCE_TIMEOUT: 700,
EXITANCE_TIMEOUT: 500,
TAB_EXIT_TIMEOUT: 480,
TEXT_TRANSITION_CONFIG: {
duration: 0.4,
},
ORNAMENT_TRANSITION_CONFIG: {
type: 'spring',
bounce: 0,
duration: 0.7,
},
} as const
const WINDOW_VARIANTS = {
hidden: {
opacity: 0,
},
visible: {
opacity: 1,
transition: {
type: 'spring',
damping: 18,
stiffness: 90,
delay: 0.25,
},
},
exit: {
opacity: 0,
transition: {
type: 'spring',
damping: 18,
stiffness: 90,
},
},
} as const
const FOOTER_VARIANTS = {
hidden: {
opacity: 0,
scale: 0.95,
},
visible: {
opacity: 1,
scale: 1,
transition: {
type: 'spring',
duration: 0.2,
bounce: 0,
delay: 0.7,
},
},
exit: {
opacity: 0,
scale: 0.95,
},
} as const
// Create a context for the active tab
const OrnamentContext = createContext<{
activeTab: string
isExpanded: boolean
isMouseDown: boolean
isUnmounting: boolean
contentClassName?: string
setIsMouseDown: React.Dispatch<React.SetStateAction<boolean>>
setActiveTab: React.Dispatch<React.SetStateAction<string>>
setIsExpanded: React.Dispatch<React.SetStateAction<boolean>>
setContentClassName: React.Dispatch<React.SetStateAction<string>>
setIsUnmounting: React.Dispatch<React.SetStateAction<boolean>>
handleOrnamentItemClick: () => void
handleOrnamentItemMouseDown: () => void
handleOrnamentItemMouseUp: () => void
handleOrnamentItemFocus: () => void
handleOrnamentItemBlur: () => void
}>({
activeTab: '',
isExpanded: false,
isMouseDown: false,
isUnmounting: false,
contentClassName: '',
setIsMouseDown: () => {},
setActiveTab: () => {},
setIsExpanded: () => {},
setContentClassName: () => {},
setIsUnmounting: () => {},
handleOrnamentItemClick: () => {},
handleOrnamentItemMouseDown: () => {},
handleOrnamentItemMouseUp: () => {},
handleOrnamentItemFocus: () => {},
handleOrnamentItemBlur: () => {},
})
export function useOrnament() {
const context = useContext(OrnamentContext)
if (!context) {
throw new Error('useOrnament must be used within a OrnamentContext')
}
return context
}
const Ornament = ({
children,
className,
defaultTab,
}: {
children: React.ReactNode
className?: string
defaultTab?: string
}) => {
const [activeTab, setActiveTab] = useState(defaultTab || '')
const [isExpanded, setIsExpanded] = useState(false)
const [isMouseDown, setIsMouseDown] = useState(false)
const [isUnmounting, setIsUnmounting] = useState(false)
const timeoutEnterRef = useRef<NodeJS.Timeout | null>(null)
const timeoutLeaveRef = useRef<NodeJS.Timeout | null>(null)
const [contentClassName, setContentClassName] = useState('')
const handleMouseEnter = () => {
if (timeoutLeaveRef.current) {
clearTimeout(timeoutLeaveRef.current)
}
timeoutEnterRef.current = setTimeout(() => {
setIsExpanded(true)
}, CONSTANTS.ENTERANCE_TIMEOUT)
}
const handleMouseLeave = () => {
if (timeoutEnterRef.current) {
clearTimeout(timeoutEnterRef.current)
}
timeoutLeaveRef.current = setTimeout(() => {
setIsExpanded(false)
}, CONSTANTS.EXITANCE_TIMEOUT)
}
const handleOrnamentItemMouseDown = () => {
setIsMouseDown(true)
}
const handleOrnamentItemMouseUp = () => {
setIsMouseDown(false)
}
const handleOrnamentItemClick = () => {
if (timeoutEnterRef.current) clearTimeout(timeoutEnterRef.current)
if (timeoutLeaveRef.current) clearTimeout(timeoutLeaveRef.current)
}
const handleOrnamentItemFocus = () => {
handleMouseEnter()
}
const handleOrnamentItemBlur = () => {
handleMouseLeave()
}
return (
<OrnamentContext.Provider
value={{
activeTab,
isExpanded,
isMouseDown,
isUnmounting,
contentClassName,
setIsUnmounting,
setIsMouseDown,
setActiveTab,
setIsExpanded,
setContentClassName,
handleOrnamentItemClick,
handleOrnamentItemMouseDown,
handleOrnamentItemMouseUp,
handleOrnamentItemFocus,
handleOrnamentItemBlur,
}}
>
<Tabs
orientation="vertical"
className={cn(
'relative grid h-full w-full flex-1 grid-cols-[68px_1fr] place-content-center gap-4 md:gap-7 lg:ml-[-96px]',
'max-w-3xl xl:max-w-4xl 2xl:max-w-6xl',
className
)}
defaultValue={defaultTab}
aria-label="Navigation and content panels"
>
<div className="sr-only">
This navigation menu expands when focused. Use tab key to navigate between tabs.
</div>
{children}
</Tabs>
</OrnamentContext.Provider>
)
}
const OrnamentTabs = ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => {
const { isExpanded, isMouseDown, handleOrnamentItemFocus, handleOrnamentItemBlur } = useOrnament()
return (
<Window
className={'hide-scrollbar relative z-[42] mx-auto flex items-center justify-start p-3'}
initial={{
opacity: 0,
scale: 1,
}}
animate={{
opacity: 1,
scale: isMouseDown ? 1 : isExpanded ? 1.05 : 1,
}}
exit={{
opacity: 0,
}}
transition={CONSTANTS.ORNAMENT_TRANSITION_CONFIG}
aria-label="Navigation tabs"
>
<TabsList asChild>
<motion.div
onMouseEnter={handleOrnamentItemFocus}
onMouseLeave={handleOrnamentItemBlur}
className={cn('flex flex-col items-start gap-2', className)}
>
{children}
</motion.div>
</TabsList>
</Window>
)
}
const OrnamentTab = ({
icon,
label,
value,
onClick,
}: {
icon: React.ReactNode
label: string
value: string
onClick?: () => void
}) => {
const {
activeTab,
setActiveTab,
isExpanded,
setIsUnmounting,
handleOrnamentItemClick,
handleOrnamentItemMouseDown,
handleOrnamentItemMouseUp,
handleOrnamentItemFocus,
handleOrnamentItemBlur,
} = useOrnament()
const [isHovered, setIsHovered] = useState(false)
const handleClick = () => {
setIsUnmounting(true)
setTimeout(() => {
setActiveTab(value)
setIsUnmounting(false)
}, CONSTANTS.TAB_EXIT_TIMEOUT)
handleOrnamentItemClick()
onClick?.()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleClick()
}
}
const isActive = activeTab === value
const variant = isHovered ? 'default' : isActive ? 'default' : 'secondary'
return (
<motion.div
initial={false}
animate={{
width: isExpanded ? CONSTANTS.EXPANDED_WIDTH : CONSTANTS.COLLAPSED_WIDTH,
}}
transition={CONSTANTS.ORNAMENT_TRANSITION_CONFIG}
>
<TabsTrigger value={value} asChild>
<Button
id={`ornament-tab-${value}`}
className="flex w-full items-center justify-stretch rounded-full px-[10px] before:rounded-full"
onClick={handleClick}
onMouseDown={handleOrnamentItemMouseDown}
onMouseUp={handleOrnamentItemMouseUp}
onFocus={handleOrnamentItemFocus}
onBlur={handleOrnamentItemBlur}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onKeyDown={handleKeyDown}
variant={variant}
aria-expanded={isExpanded}
>
<div
className="relative mr-4 flex-shrink-0 [&_[data-slot='icon']]:size-6"
aria-hidden="true"
>
{icon}
</div>
<motion.span
className="flex-1 overflow-hidden text-start"
initial={{
width: 0,
}}
animate={{
width: isExpanded ? 'auto' : 0,
}}
transition={CONSTANTS.TEXT_TRANSITION_CONFIG}
>
<Text
size="title3"
variant={isHovered ? 'default' : variant}
className="line-clamp-1 w-fit min-w-[60px] truncate font-medium leading-[24px]"
>
{label}
</Text>
</motion.span>
</Button>
</TabsTrigger>
</motion.div>
)
}
interface OrnamentContentsApiProps {
children: React.ReactNode
/**
* A helper class name for all children `OrnamentContent` components.
*
* You can also customize each `OrnamentContent` component individually.
*/
contentClassName?: string
}
const OrnamentContents = ({ children, contentClassName }: OrnamentContentsApiProps) => {
const { setContentClassName } = useOrnament()
React.useEffect(() => {
if (contentClassName) {
setContentClassName(contentClassName)
}
}, [contentClassName, setContentClassName])
return children
}
interface OrnamentContentApiProps {
value: string
/**
* Rendered at the top of the content. Can be a React Component (e.g. SomeComponent), or a React element (e.g. <SomeComponent />).
*/
HeaderComponent?: React.ReactNode | React.ComponentType
/**
* Rendered at the bottom of the content. Can be a React Component (e.g. SomeComponent), or a React element (e.g. <SomeComponent />).
*/
FooterComponent?: React.ReactNode | React.ComponentType
}
interface OrnamentContentProps extends WindowProps, OrnamentContentApiProps {}
const OrnamentContent = ({
value,
HeaderComponent,
FooterComponent,
className,
rootClassName,
...props
}: OrnamentContentProps) => {
const { activeTab, contentClassName } = useOrnament()
const isActive = activeTab === value
return (
<AnimatePresence mode="popLayout">
{isActive && (
<TabsContent
value={value}
key={`ornament-content-${value}`}
forceMount
className={cn('relative order-2 flex w-full flex-col', className)}
data-slot="content"
id={`ornament-content-${value}`}
tabIndex={-1}
>
{HeaderComponent &&
(typeof HeaderComponent === 'function' ? <HeaderComponent /> : HeaderComponent)}
<Window
className={cn(contentClassName, className)}
rootClassName={cn('items-stretch', rootClassName)}
scroll
initial={WINDOW_VARIANTS.hidden}
animate={WINDOW_VARIANTS.visible}
exit={WINDOW_VARIANTS.exit}
transition={CONSTANTS.ORNAMENT_TRANSITION_CONFIG}
{...props}
/>
{FooterComponent && (
<motion.div
initial={FOOTER_VARIANTS.hidden}
animate={FOOTER_VARIANTS.visible}
exit={FOOTER_VARIANTS.exit}
transition={{
duration: 0.1,
}}
className="absolute right-0 bottom-0 left-0 z-[41] flex items-center justify-center"
>
{typeof FooterComponent === 'function' ? <FooterComponent /> : FooterComponent}
</motion.div>
)}
</TabsContent>
)}
</AnimatePresence>
)
}
/**
* A motion.div without a window, header or footer.
*/
const OrnamentContentBase = ({
children,
value,
className,
}: {
children: React.ReactNode
value: string
className?: string
}) => {
const { activeTab, contentClassName, isUnmounting } = useOrnament()
const isActive = activeTab === value
return (
<AnimatePresence mode="popLayout">
{isActive && !isUnmounting && (
<TabsContent
value={value}
key={`ornament-content-${value}-active`}
forceMount
className="order-2 flex w-full flex-col"
tabIndex={-1}
asChild
>
<motion.div
className={cn(className, contentClassName)}
initial={WINDOW_VARIANTS.hidden}
animate={WINDOW_VARIANTS.visible}
exit={WINDOW_VARIANTS.exit}
transition={CONSTANTS.ORNAMENT_TRANSITION_CONFIG}
>
{children}
</motion.div>
</TabsContent>
)}
</AnimatePresence>
)
}
export {
Ornament,
OrnamentTabs,
OrnamentTab,
OrnamentContents,
OrnamentContent,
OrnamentContentBase,
}
export type { OrnamentContentApiProps, OrnamentContentsApiProps }
API
OrnamentContents
Prop | Type | Default |
---|---|---|
children | ReactNode | - |
contentClassName? | string | - |
OrnamentContent
Prop | Type | Default |
---|---|---|
value | string | - |
HeaderComponent? | ReactNode | ComponentType<{}> | - |
FooterComponent? | ReactNode | ComponentType<{}> | - |
Edit on GitHub
Last updated on