Vision UI

Ornament

Vertical tab bar with custom scrollbar

This navigation menu expands when focused. Use tab key to navigate between tabs.
Scrollable content
End
"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

PropTypeDefault
children
ReactNode
-
contentClassName?
string
-

OrnamentContent

PropTypeDefault
value
string
-
HeaderComponent?
ReactNode | ComponentType<{}>
-
FooterComponent?
ReactNode | ComponentType<{}>
-
Edit on GitHub

Last updated on