Skip to content

Split Islands

Logo and menus on one island, actions on another, the page between.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/split-islands.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/nav/mobile-menu.tsx
    "use client";
    
    import { useEffect, useId, useState } from "react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { ChevronDown } from "lucide-react";
    import { cn } from "@/lib/utils";
    import type { NavAction, NavItem } from "@/lib/nav/types";
    
    export interface MobileMenuProps {
      items: NavItem[];
      actions?: NavAction[];
      /** Colors for light or dark navbars. @default "light" */
      tone?: "light" | "dark";
      /** Classes for the toggle button. */
      className?: string;
      /** Classes for the panel, to place it under narrow navbars. */
      panelClassName?: string;
    }
    
    /**
     * The compact menu every navbar falls back to on narrow widths: a hamburger that morphs into an X,
     * and a panel with expandable groups and the actions. Escape closes it and returns focus.
     */
    export function MobileMenu({ items, actions = [], tone = "light", className, panelClassName }: MobileMenuProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const [open, setOpen] = useState(false);
      const [group, setGroup] = useState<string | null>(null);
      const dark = tone === "dark";
    
      useEffect(() => {
        if (!open) return;
        const onKey = (e: KeyboardEvent) => {
          if (e.key !== "Escape") return;
          setOpen(false);
          document.getElementById(`${id}-toggle`)?.focus();
        };
        window.addEventListener("keydown", onKey);
        return () => window.removeEventListener("keydown", onKey);
      }, [open, id]);
    
      const line = cn("absolute left-1/2 h-[1.5px] w-4 -translate-x-1/2 rounded-full transition-transform duration-300 motion-reduce:transition-none", dark ? "bg-white" : "bg-[#0b0b0c]");
    
      return (
        <>
          <button
            id={`${id}-toggle`}
            type="button"
            aria-expanded={open}
            aria-controls={`${id}-panel`}
            aria-label={open ? "Close menu" : "Open menu"}
            onClick={() => setOpen((o) => !o)}
            className={cn("relative grid size-9 shrink-0 cursor-pointer place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring", dark ? "hover:bg-white/10" : "hover:bg-black/5", className)}
          >
            <span aria-hidden="true" className={cn(line, open ? "top-1/2 rotate-45" : "top-[13px]")} />
            <span aria-hidden="true" className={cn(line, open ? "top-1/2 -rotate-45" : "top-[21px]")} />
          </button>
          <AnimatePresence>
            {open ? (
              <motion.div
                id={`${id}-panel`}
                initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.98 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={{ opacity: 0, transition: { duration: 0.12 } }}
                transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
                className={cn("absolute inset-x-2 top-full z-50 mt-2 origin-top rounded-2xl border p-2 shadow-xl", dark ? "border-white/10 bg-[#111113] text-white" : "border-black/[0.06] bg-white text-[#0b0b0c]", panelClassName)}
              >
                <ul>
                  {items.map((item) =>
                    item.items?.length ? (
                      <li key={item.label}>
                        <button
                          type="button"
                          aria-expanded={group === item.label}
                          onClick={() => setGroup((g) => (g === item.label ? null : item.label))}
                          className={cn("flex h-11 w-full cursor-pointer items-center justify-between rounded-xl px-3 text-[15px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", dark ? "hover:bg-white/5" : "hover:bg-black/[0.03]")}
                        >
                          {item.label}
                          <ChevronDown className={cn("size-4 opacity-60 transition-transform", group === item.label && "rotate-180")} aria-hidden="true" />
                        </button>
                        <div className={cn("grid transition-[grid-template-rows] duration-300 motion-reduce:transition-none", group === item.label ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>
                          <ul className="min-h-0 overflow-hidden" inert={group !== item.label}>
                            {item.items.map((link) => (
                              <li key={link.label}>
                                <a href={link.href} className={cn("flex items-center gap-2.5 rounded-lg py-2 pr-3 pl-6 text-[14px] outline-none focus-visible:ring-2 focus-visible:ring-ring", dark ? "text-white/75 hover:text-white" : "text-[#52525b] hover:text-[#0b0b0c]")}>
                                  {link.icon ? <span aria-hidden="true" className="opacity-70 [&_svg]:size-4">{link.icon}</span> : null}
                                  {link.label}
                                </a>
                              </li>
                            ))}
                          </ul>
                        </div>
                      </li>
                    ) : (
                      <li key={item.label}>
                        <a href={item.href ?? "#"} className={cn("flex h-11 items-center rounded-xl px-3 text-[15px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", dark ? "hover:bg-white/5" : "hover:bg-black/[0.03]")}>
                          {item.label}
                        </a>
                      </li>
                    ),
                  )}
                </ul>
                {actions.length ? (
                  <div className={cn("mt-2 grid gap-2 border-t pt-3", dark ? "border-white/10" : "border-black/[0.06]")}>
                    {actions.map((a, i) => (
                      <a
                        key={a.label}
                        href={a.href}
                        className={cn(
                          "flex h-11 items-center justify-center rounded-xl text-[14px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring",
                          i === actions.length - 1 ? (dark ? "bg-white text-[#0b0b0c]" : "bg-[#0b0b0c] text-white") : dark ? "border border-white/15" : "border border-black/10",
                        )}
                      >
                        {a.label}
                      </a>
                    ))}
                  </div>
                ) : null}
              </motion.div>
            ) : null}
          </AnimatePresence>
        </>
      );
    }
    lib/nav/types.ts
    import type { ReactNode } from "react";
    
    /** A link in a menu or panel. */
    export interface NavLink {
      label: string;
      href: string;
      /** A short line under the label in rich menus. */
      description?: string;
      /** An icon shown beside the label in rich menus. */
      icon?: ReactNode;
    }
    
    /** A top-level navbar entry: a plain link, or a trigger with links under it. */
    export interface NavItem {
      label: string;
      /** Destination when the item is a plain link. */
      href?: string;
      /** Links shown in the item's menu; makes the item a menu trigger. */
      items?: NavLink[];
    }
    
    /** A call-to-action button. */
    export interface NavAction {
      label: string;
      href: string;
    }
    lib/nav/use-menu.ts
    "use client";
    
    import { useEffect, useRef, useState, type RefObject } from "react";
    
    /**
     * Open/close state for a navbar's menus: one menu open at a time, opened by click or by hover with a
     * short intent delay, closed by Escape (focus returns to its trigger), an outside click, or the
     * pointer leaving the navbar.
     */
    export function useMenu(root: RefObject<HTMLElement | null>) {
      const [open, setOpen] = useState<string | null>(null);
      const timer = useRef(0);
      const triggers = useRef(new Map<string, HTMLElement>());
    
      useEffect(() => {
        if (!open) return;
        const onKey = (e: KeyboardEvent) => {
          if (e.key !== "Escape") return;
          triggers.current.get(open)?.focus();
          setOpen(null);
        };
        const onDown = (e: PointerEvent) => {
          if (root.current && !root.current.contains(e.target as Node)) setOpen(null);
        };
        window.addEventListener("keydown", onKey);
        window.addEventListener("pointerdown", onDown);
        return () => {
          window.removeEventListener("keydown", onKey);
          window.removeEventListener("pointerdown", onDown);
        };
      }, [open, root]);
    
      useEffect(() => () => window.clearTimeout(timer.current), []);
    
      const hoverOpen = (id: string) => {
        window.clearTimeout(timer.current);
        timer.current = window.setTimeout(() => setOpen(id), open ? 0 : 90);
      };
      const hoverClose = () => {
        window.clearTimeout(timer.current);
        timer.current = window.setTimeout(() => setOpen(null), 160);
      };
      const cancelClose = () => window.clearTimeout(timer.current);
    
      /** Props for a menu trigger button. */
      const triggerProps = (id: string, panelId: string) => ({
        ref: (el: HTMLElement | null) => {
          if (el) triggers.current.set(id, el);
          else triggers.current.delete(id);
        },
        "aria-expanded": open === id,
        "aria-controls": panelId,
        onClick: () => setOpen((o) => (o === id ? null : id)),
        onPointerEnter: (e: React.PointerEvent) => e.pointerType === "mouse" && hoverOpen(id),
        onPointerLeave: (e: React.PointerEvent) => e.pointerType === "mouse" && hoverClose(),
      });
    
      /** Props for a menu panel, so moving onto it keeps it open. */
      const panelProps = {
        onPointerEnter: cancelClose,
        onPointerLeave: (e: React.PointerEvent) => e.pointerType === "mouse" && hoverClose(),
      };
    
      return { open, setOpen, triggerProps, panelProps, close: () => setOpen(null) };
    }
  3. Copy the source

    components/ui/split-islands/split-islands.tsx
    "use client";
    
    import { useId, useRef, type ReactNode } from "react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { ChevronDown } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { MobileMenu } from "@/lib/nav/mobile-menu";
    import type { NavAction, NavItem } from "@/lib/nav/types";
    import { useMenu } from "@/lib/nav/use-menu";
    
    export interface SplitIslandsProps {
      /** Logo mark (an icon works best on the left island). */
      brand: ReactNode;
      /** Menus on the left island; entries with `items` open a dropdown. */
      items: NavItem[];
      /** Plain links on the right island, before the actions, e.g. Pricing. */
      links?: NavItem[];
      /** An outlined action, e.g. "Log in". */
      secondary?: NavAction;
      /** The filled call to action. */
      cta?: NavAction;
      /** Accessible name for the navigation. @default "Main" */
      label?: string;
      /** Classes for the header. */
      className?: string;
    }
    
    /**
     * Two separate floating islands: brand and menus on the left, links and actions on the right, with the
     * page showing through the space between. Menus open under the left island with a soft fade.
     */
    export function SplitIslands({ brand, items, links = [], secondary, cta, label = "Main", className }: SplitIslandsProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const root = useRef<HTMLElement>(null);
      const menu = useMenu(root);
      const island = "flex h-12 items-center gap-0.5 rounded-xl border border-black/[0.05] bg-white px-1.5 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.3)]";
      const pill = "flex h-9 relative after:absolute after:inset-x-0 after:-inset-y-0.5 whitespace-nowrap items-center rounded-lg px-3 text-[14px] font-medium text-[#18181b] outline-none hover:bg-black/[0.04] focus-visible:ring-2 focus-visible:ring-ring";
      return (
        <header ref={root} className={cn("@container sticky top-0 z-40 px-3 pt-3", className)}>
          <nav aria-label={label} className="relative flex items-start justify-between gap-3">
            <div className={island}>
              <div className="px-2.5">{brand}</div>
              <ul className="hidden items-center gap-0.5 @2xl:flex">
                {items.map((item) => {
                  const panelId = `${id}-${item.label}`;
                  const open = menu.open === item.label;
                  if (!item.items?.length) {
                    return (
                      <li key={item.label}>
                        <a href={item.href ?? "#"} className={pill}>
                          {item.label}
                        </a>
                      </li>
                    );
                  }
                  return (
                    <li key={item.label} className="relative">
                      <button type="button" {...menu.triggerProps(item.label, panelId)} className={cn(pill, "cursor-pointer gap-1", open && "bg-black/[0.05]")}>
                        {item.label}
                        <ChevronDown className={cn("size-3.5 transition-transform", open && "rotate-180")} aria-hidden="true" />
                      </button>
                      <AnimatePresence>
                        {open ? (
                          <motion.div
                            id={panelId}
                            {...menu.panelProps}
                            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 4, filter: "blur(4px)" }}
                            animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                            exit={{ opacity: 0, transition: { duration: 0.1 } }}
                            transition={{ duration: 0.2 }}
                            className="absolute top-full left-0 z-50 mt-3 w-72 rounded-xl border border-black/[0.06] bg-white p-2 shadow-xl"
                          >
                            <ul>
                              {item.items.map((link) => (
                                <li key={link.label}>
                                  <a href={link.href} onClick={menu.close} className="block rounded-lg px-3 py-2 outline-none hover:bg-black/[0.04] focus-visible:bg-black/[0.04]">
                                    <span className="block text-[15px] font-medium text-[#18181b]">{link.label}</span>
                                    {link.description ? <span className="block text-[12.5px] text-[#71717a]">{link.description}</span> : null}
                                  </a>
                                </li>
                              ))}
                            </ul>
                          </motion.div>
                        ) : null}
                      </AnimatePresence>
                    </li>
                  );
                })}
              </ul>
            </div>
            <div className={cn(island, "hidden gap-1 @2xl:flex")}>
              {links.map((link) => (
                <a key={link.label} href={link.href ?? "#"} className={pill}>
                  {link.label}
                </a>
              ))}
              {secondary ? (
                <a href={secondary.href} className="flex h-9 relative after:absolute after:inset-x-0 after:-inset-y-0.5 whitespace-nowrap items-center rounded-full border border-black/80 px-4 text-[14px] font-medium text-[#18181b] outline-none hover:bg-black/[0.04] focus-visible:ring-2 focus-visible:ring-ring">
                  {secondary.label}
                </a>
              ) : null}
              {cta ? (
                <a href={cta.href} className="flex h-9 relative after:absolute after:inset-x-0 after:-inset-y-0.5 whitespace-nowrap items-center rounded-full bg-[#0b0b0c] px-4 text-[14px] font-medium text-white outline-none hover:bg-black/80 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
                  {cta.label}
                </a>
              ) : null}
            </div>
            <div className={cn(island, "@2xl:hidden")}>
              <MobileMenu items={[...items, ...links]} actions={[secondary, cta].filter(Boolean) as NavAction[]} />
            </div>
          </nav>
        </header>
      );
    }

Usage

import { SplitIslands } from "@/components/ui/split-islands/split-islands";

<SplitIslands brand={<LogoMark />} items={menus} links={[{ label: "Pricing", href: "/pricing" }]} secondary={login} cta={signup} />

API reference

PropTypeDefaultDescription
brand*ReactNode–Logo mark (an icon works best on the left island).
items*NavItem[]–Menus on the left island; entries with `items` open a dropdown.
linksNavItem[]–Plain links on the right island, before the actions, e.g. Pricing.
secondaryNavAction–An outlined action, e.g. "Log in".
ctaNavAction–The filled call to action.
labelstring"Main"Accessible name for the navigation.
classNamestring–Classes for the header.
  • The left island holds the logo mark and the menus, the right one plain links and actions.
  • Dropdowns blur in as they open; reduced motion keeps a plain fade.

Accessibility

  • Two islands share one `<nav>` landmark, so screen readers hear one navigation.
  • Menu triggers expose `aria-expanded`; Escape and outside clicks close them.
  • Give an icon-only brand an `aria-label`.