# Navbar Corner Panel

> A navbar that keeps its whole menu in one small frosted square in the corner. Opening it grows the square into a tall panel whose links drop in with their underlines drawing out, while the page dims behind and a lime close bubble follows the pointer.

A shadcn/ui block from the [@tween-ui](https://tween-ui.vercel.app) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/tween-ui/navbar-corner-panel).

- Page, with a live preview: https://shadcn.exchange/r/tween-ui/navbar-corner-panel
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/tween-ui/navbar-corner-panel-light-11dbb6f5477a558b.png
- Registry item: https://tween-ui.vercel.app/r/navbar-corner-panel.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://tween-ui.vercel.app/r/navbar-corner-panel.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@gsap/react`, `gsap`

## Notes from the registry

Docs, props and examples: https://tween-ui.vercel.app/block/navbar-corner-panel
Pass your own `links` and `socials` and put your page inside as `children`; the panel sizes itself to the screen it opens on.

## Files

### `components/tweenui/navbar-corner-panel.tsx`

```tsx
'use client';

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type ComponentPropsWithoutRef,
  type MouseEvent,
  type ReactNode,
} from 'react';
import { useGSAP } from '@gsap/react';
import gsap from 'gsap';
import { cn } from '@/lib/utils';

gsap.registerPlugin(useGSAP);

const prefersReducedMotion = () =>
  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

export interface NavbarCornerPanelLink {
  label: string;
  href: string;
}

export interface NavbarCornerPanelProps extends Omit<
  ComponentPropsWithoutRef<'section'>,
  'children'
> {
  brand?: string;
  logo?: ReactNode;
  homeHref?: string;
  links?: NavbarCornerPanelLink[];
  currentHref?: string;
  socials?: NavbarCornerPanelLink[];
  children?: ReactNode;
}

type PanelSize = {
  width: number;
  height: number;
};

const REPO = 'https://github.com/StaticMania/tween-ui';

const DEFAULT_LINKS: NavbarCornerPanelLink[] = [
  { label: 'Home', href: '/' },
  { label: 'Components', href: '/components' },
  { label: 'Blocks', href: '/components#blocks' },
  { label: 'Setup', href: '/installation/setup-guide' },
];

const DEFAULT_SOCIALS: NavbarCornerPanelLink[] = [
  { label: 'GitHub', href: REPO },
  { label: 'Issues', href: `${REPO}/issues` },
  { label: 'Changelog', href: `${REPO}/blob/main/CHANGELOG.md` },
];

const TWEEN_MARK = (
  <svg aria-hidden="true" viewBox="0 0 24 24" className="size-8">
    <rect width="24" height="24" rx="6" fill="#045f64" />
    <path
      d="M5 19 C 9.5 8.9, 5 5, 19 5"
      fill="none"
      stroke="#c6f56f"
      strokeWidth="2"
      strokeLinecap="round"
    />
    <circle cx="19" cy="5" r="1.8" fill="#c6f56f" />
  </svg>
);

const CONTAINER = 'mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8';
const TOGGLE_SIZE = 44;
const GAP = 16;
const LARGE = 1024;
const MEDIUM = 768;

const EASE = 'ease-[cubic-bezier(0,0,0,1)]';

const focusRing =
  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c6f56f]';

const cleanHref = (href: string) => href.split(/[?#]/)[0]?.replace(/\/+$/, '') || '/';

const isExternal = (href: string) => /^https?:/.test(href);

const externalProps = (href: string) =>
  isExternal(href) ? { target: '_blank', rel: 'noopener noreferrer' } : {};

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

function ArrowIcon({ className }: { className?: string }) {
  return (
    <svg aria-hidden="true" viewBox="0 0 14 15" fill="none" className={className}>
      <path
        d="M3.06978 1.94975L12.9693 1.94973L12.9693 11.8492M0.948451 13.9705L12.6157 2.3033"
        stroke="currentColor"
        strokeWidth="2"
      />
    </svg>
  );
}

function SamplePage({ linkCount }: { linkCount: number }) {
  const headingId = useId();

  return (
    <section
      aria-labelledby={headingId}
      className="relative isolate flex h-full min-h-full flex-col overflow-hidden pt-24 pb-10 sm:pt-28 sm:pb-14"
    >
      <span
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 -z-10 bg-[repeating-linear-gradient(90deg,rgb(159_212_214/0.06)_0_1px,transparent_1px_48px),repeating-linear-gradient(0deg,rgb(159_212_214/0.045)_0_1px,transparent_1px_48px)] [mask-image:radial-gradient(ellipse_75%_65%_at_50%_40%,black,transparent)]"
      />
      <span
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(40%_50%_at_96%_4%,rgb(198_245_111/0.14),transparent_70%),radial-gradient(55%_60%_at_8%_100%,rgb(4_95_100/0.6),transparent_72%),radial-gradient(40%_36%_at_45%_0%,rgb(159_212_214/0.08),transparent_70%)]"
      />
      <div className={cn(CONTAINER, 'flex flex-1 items-center')}>
        <div className="mx-auto max-w-4xl text-center">
          <p className="inline-flex items-center gap-2 text-sm text-[#9fd4d6]/70">
            <span aria-hidden="true" className="text-[#c6f56f]">
              +
            </span>
            ({linkCount} pages, one corner)
          </p>
          <h2
            id={headingId}
            className="mt-5 text-[40px] leading-[0.98] font-medium tracking-[-0.045em] text-balance text-[#e8f3f2] sm:text-6xl md:text-7xl"
          >
            Everything waits in the <span className="text-[#c6f56f]">corner.</span>
          </h2>
          <p className="mx-auto mt-6 max-w-[34rem] text-[15px] leading-relaxed text-pretty text-[#e8f3f2]/60 sm:text-base md:text-lg">
            Open the square at the top right and it grows into the menu, straight out of its corner.
            Click anywhere outside to put it away.
          </p>
        </div>
      </div>
    </section>
  );
}

export default function NavbarCornerPanel({
  brand = 'Tween UI',
  logo = TWEEN_MARK,
  homeHref = '/',
  links = DEFAULT_LINKS,
  currentHref = '/',
  socials = DEFAULT_SOCIALS,
  children,
  className,
  ...props
}: NavbarCornerPanelProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [size, setSize] = useState<PanelSize>({ width: 0, height: 0 });
  const menuId = useId();

  const rootRef = useRef<HTMLElement>(null);
  const anchorRef = useRef<HTMLDivElement>(null);
  const toggleRef = useRef<HTMLButtonElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const overlayRef = useRef<HTMLButtonElement>(null);
  const cursorRef = useRef<HTMLSpanElement>(null);
  const isOpenRef = useRef(false);
  const timelineRef = useRef<gsap.core.Timeline | null>(null);

  const current = links.findIndex((link) => cleanHref(link.href) === cleanHref(currentHref));

  const measure = useCallback((): PanelSize => {
    const root = rootRef.current;
    const anchor = anchorRef.current;
    if (!root || !anchor) return { width: 0, height: 0 };
    const rootBox = root.getBoundingClientRect();
    const anchorBox = anchor.getBoundingClientRect();
    const top = anchorBox.top - rootBox.top;
    const reach = anchorBox.right - rootBox.left - GAP;
    const rootWidth = rootBox.width;
    const height = Math.round(rootBox.height - top * 2);

    let width = reach;
    if (rootWidth >= LARGE) width = clamp(rootWidth * 0.32, 340, 440);
    else if (rootWidth >= MEDIUM) width = rootWidth * 0.48;
    width = Math.round(Math.min(width, reach));

    return { width, height };
  }, []);

  const { contextSafe } = useGSAP({ scope: rootRef });

  const animatePanel = contextSafe((open: boolean, next: PanelSize) => {
    const panel = panelRef.current;
    const overlay = overlayRef.current;
    if (!panel || !overlay) return;
    const items = gsap.utils.toArray<HTMLElement>('[data-panel-item]', panel);
    const extras = gsap.utils.toArray<HTMLElement>('[data-panel-extra]', panel);
    const lines = gsap.utils.toArray<HTMLElement>('[data-panel-line]', panel);
    timelineRef.current?.kill();

    timelineRef.current = open
      ? gsap
          .timeline({ defaults: { duration: 0.5 } })
          .set(panel, { visibility: 'visible' }, 0)
          .set([...items, ...extras], { y: '-2rem', opacity: 0 }, 0)
          .set(lines, { scaleX: 0 }, 0)
          .fromTo(overlay, { autoAlpha: 0 }, { autoAlpha: 1 }, 0)
          .to(panel, { width: next.width, height: next.height, ease: 'power1.out' }, 0)
          .to(items, { y: 0, opacity: 1, stagger: 0.05 }, 0)
          .to(lines, { scaleX: 1, stagger: 0.05, ease: 'power2.out' }, 0.15)
          .to(extras, { y: 0, opacity: 1, stagger: 0.05 }, 0.2)
      : gsap
          .timeline({ defaults: { duration: 0.5 } })
          .to([...items, ...extras], { y: '-2rem', opacity: 0, stagger: 0.03, duration: 0.3 }, 0)
          .to(overlay, { autoAlpha: 0 }, 0)
          .to(panel, { width: TOGGLE_SIZE, height: TOGGLE_SIZE, ease: 'power1.inOut' }, 0)
          .set(panel, { visibility: 'hidden' });

    if (prefersReducedMotion()) timelineRef.current.progress(1);
  });

  const setOpen = useCallback(
    (open: boolean) => {
      if (open === isOpenRef.current) return;
      isOpenRef.current = open;
      const next = measure();
      setSize(next);
      setIsOpen(open);
      animatePanel(open, next);
    },
    [animatePanel, measure]
  );

  const showCursor = contextSafe((isVisible: boolean) => {
    gsap.to(cursorRef.current, {
      autoAlpha: isVisible ? 1 : 0,
      scale: isVisible ? 1 : 0.5,
      duration: 0.25,
      ease: 'power2.out',
    });
  });

  useEffect(() => {
    const root = rootRef.current;
    const cursor = cursorRef.current;
    if (!isOpen || !root || !cursor) return;

    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key !== 'Escape') return;
      setOpen(false);
      toggleRef.current?.focus();
    };

    const moveX = gsap.quickTo(cursor, 'x', { duration: 0.22, ease: 'power3.out' });
    const moveY = gsap.quickTo(cursor, 'y', { duration: 0.22, ease: 'power3.out' });
    const handlePointerMove = (event: PointerEvent) => {
      if (event.pointerType === 'touch') {
        showCursor(false);
        return;
      }
      const box = root.getBoundingClientRect();
      moveX(event.clientX - box.left);
      moveY(event.clientY - box.top);
      const target = event.target instanceof Element ? event.target : null;
      showCursor(!target?.closest('[data-menu-keep]'));
    };
    const handlePointerLeave = () => showCursor(false);

    const observer = new ResizeObserver(() => {
      if (timelineRef.current?.isActive()) return;
      const next = measure();
      setSize(next);
      gsap.set(panelRef.current, { width: next.width, height: next.height });
    });

    gsap.set(cursor, { xPercent: -50, yPercent: -50 });
    document.addEventListener('keydown', handleKeyDown);
    root.addEventListener('pointermove', handlePointerMove);
    root.addEventListener('pointerleave', handlePointerLeave);
    observer.observe(root);
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      root.removeEventListener('pointermove', handlePointerMove);
      root.removeEventListener('pointerleave', handlePointerLeave);
      observer.disconnect();
      showCursor(false);
    };
  }, [isOpen, measure, setOpen, showCursor]);

  const handleLinkClick = (event: MouseEvent<HTMLAnchorElement>, index: number) => {
    if (index !== current) return;
    event.preventDefault();
    setOpen(false);
  };

  return (
    <section
      ref={rootRef}
      aria-label={`${brand} site`}
      className={cn(
        'relative isolate h-svh min-h-[560px] w-full overflow-hidden bg-[#03110f] text-[#e8f3f2]',
        className
      )}
      {...props}
    >
      <div
        inert={isOpen}
        data-lenis-prevent=""
        className="absolute inset-0 z-0 [scrollbar-width:none] overflow-y-auto [&::-webkit-scrollbar]:hidden"
      >
        {children ?? <SamplePage linkCount={links.length} />}
      </div>

      <button
        ref={overlayRef}
        type="button"
        tabIndex={-1}
        aria-hidden="true"
        onClick={() => setOpen(false)}
        className="invisible absolute inset-0 z-10 bg-[#03110f]/55 opacity-0 backdrop-blur-[2px] [@media(pointer:fine)]:cursor-none"
      />

      <header className="pointer-events-none absolute inset-x-0 top-0 z-20">
        <div className={cn(CONTAINER, 'relative flex items-start justify-between pt-6 sm:pt-8')}>
          <a
            href={homeHref}
            aria-label={`${brand} home`}
            className={cn(
              'pointer-events-auto flex items-center gap-2.5 rounded-lg text-[#e8f3f2]',
              focusRing
            )}
          >
            {logo}
            <span className="text-lg font-semibold tracking-tight">{brand}</span>
          </a>

          <div ref={anchorRef} data-menu-keep="" className="pointer-events-auto relative size-11">
            <div
              ref={panelRef}
              id={menuId}
              inert={!isOpen}
              className="invisible absolute top-0 right-0 size-11 overflow-hidden rounded-lg bg-[#071d1b]/85 ring-1 ring-[#9fd4d6]/12 backdrop-blur-md"
            >
              <div
                style={{ width: size.width || undefined, height: size.height || undefined }}
                className="absolute top-0 right-0"
              >
                <nav
                  aria-label="Primary"
                  className="@container flex h-full flex-col justify-between pt-16 pr-6 pb-8 pl-6 sm:pt-18 sm:pr-12 sm:pb-10 sm:pl-10"
                >
                  <ul className="flex flex-col gap-4">
                    {links.map((link, index) => (
                      <li key={`${link.label}-${link.href}`} data-panel-item="">
                        <a
                          href={link.href}
                          {...externalProps(link.href)}
                          aria-current={index === current ? 'page' : undefined}
                          onClick={(event) => handleLinkClick(event, index)}
                          className={cn(
                            'group/link flex flex-col gap-3 rounded-sm text-4xl font-normal tracking-tight transition-colors duration-200 ease-out select-none hover:text-[#e8f3f2] sm:gap-4 @[22rem]:text-5xl',
                            focusRing,
                            index === current ? 'text-[#e8f3f2]' : 'text-[#9fd4d6]/45'
                          )}
                        >
                          <span className="flex items-center justify-between gap-4">
                            <span className="min-w-0 origin-left truncate transition-transform duration-200 ease-out group-hover/link:scale-105">
                              {link.label}
                            </span>
                            <span className="block w-3.5 shrink-0 transition-transform duration-200 ease-out group-hover/link:translate-x-1">
                              <ArrowIcon
                                className={cn(
                                  'w-full',
                                  index === current ? 'rotate-90 text-[#c6f56f]' : 'rotate-45'
                                )}
                              />
                            </span>
                          </span>
                          <span
                            aria-hidden="true"
                            data-panel-line=""
                            className="block h-px w-full origin-left bg-[#9fd4d6]/15"
                          />
                        </a>
                      </li>
                    ))}
                  </ul>
                  {socials.length > 0 ? (
                    <div className="flex w-fit flex-col gap-1 tracking-tight">
                      <p data-panel-extra="" className="mb-4 text-[#9fd4d6]/70 select-none sm:mb-6">
                        Elsewhere
                      </p>
                      <ul className="flex flex-col gap-1">
                        {socials.map((social) => (
                          <li key={social.href} data-panel-extra="">
                            <a
                              href={social.href}
                              {...externalProps(social.href)}
                              className={cn(
                                'group/social relative flex items-center gap-2 overflow-hidden rounded-xs pr-4 text-[#e8f3f2] sm:gap-0',
                                focusRing
                              )}
                            >
                              <span className="block w-2.5 origin-left transition-transform duration-200 ease-out sm:scale-0 sm:group-hover/social:scale-100">
                                <ArrowIcon className="w-full text-[#c6f56f]" />
                              </span>
                              <span className="transition-transform duration-200 ease-out sm:-translate-x-2.5 sm:group-hover/social:translate-x-1">
                                {social.label}
                              </span>
                            </a>
                          </li>
                        ))}
                      </ul>
                    </div>
                  ) : null}
                </nav>
              </div>
            </div>

            <button
              ref={toggleRef}
              type="button"
              aria-label={isOpen ? 'Close menu' : 'Open menu'}
              aria-expanded={isOpen}
              aria-controls={menuId}
              onClick={() => setOpen(!isOpenRef.current)}
              className={cn(
                'absolute inset-0 z-20 grid cursor-pointer place-items-center rounded-lg backdrop-blur-md transition-colors duration-300',
                isOpen ? 'bg-transparent' : 'bg-[#071d1b]/85 ring-1 ring-[#9fd4d6]/12',
                focusRing
              )}
            >
              <span aria-hidden="true" className="relative block h-2.5 w-5">
                <span
                  className={cn(
                    'absolute inset-x-0 top-0 h-0.5 bg-[#e8f3f2] transition-transform duration-400 motion-reduce:transition-none',
                    EASE,
                    isOpen && 'translate-y-1 rotate-45'
                  )}
                />
                <span
                  className={cn(
                    'absolute inset-x-0 bottom-0 h-0.5 bg-[#e8f3f2] transition-transform duration-400 motion-reduce:transition-none',
                    EASE,
                    isOpen && '-translate-y-1 -rotate-45'
                  )}
                />
              </span>
            </button>
          </div>
        </div>
      </header>

      <span
        ref={cursorRef}
        aria-hidden="true"
        className="pointer-events-none invisible absolute top-0 left-0 z-30 grid size-14 place-items-center rounded-full bg-[#c6f56f] text-[#03110f] opacity-0"
      >
        <svg
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          className="size-5"
        >
          <path d="M18 6 6 18M6 6l12 12" />
        </svg>
      </span>
    </section>
  );
}
```
