← All Artifacts

Application Shell · Block

Adaptive Application Shell

A neutral application shell with a desktop sidebar and PWA-native touch navigation.

Free SurfaceMIT sourceGenerated from Catalog index
Live source preview

Open the live preview in a new page →

Install

One standard shadcn command

The Consumer receives editable source in their application. The Catalog is not a runtime dependency.

pnpm dlx shadcn@latest add https://shadcnpwa.pages.dev/registry/items/adaptive-app-shell/registry.json

AI handoff

Prompt for your coding agent

Install Adaptive Application Shell from the public shadcn registry, then adapt the owned source to my product. Keep the Desktop and Touch presentations, preserve keyboard and screen-reader behavior, and connect my controlled data and actions without adding network requests or persistence.

Full source

Inspect before you install.

Written for the Catalog with synthetic destinations and host-owned navigation state.

// src/adaptive-app-shell.tsx
import {
  useEffect,
  useId,
  useRef,
  useState,
  type ComponentType,
  type ReactNode,
} from "react";
import { Button } from "@/components/ui/button";
import { Sheet } from "@/components/ui/sheet";
import { useAdaptivePresentation, type Presentation } from "./use-adaptive-presentation";
import "./adaptive-artifact.css";

const TOUCH_NAVIGATION_SLOTS = 4;

export interface NavigationItem {
  id: string;
  label: string;
  href: string;
  icon?: ReactNode;
  priority?: "primary" | "more";
  permission?: boolean;
}

export interface AppShellCopy {
  primaryNavigation?: string;
  more?: string;
  moreNavigation?: string;
  closeMoreNavigation?: string;
  pendingPresentation?: string;
}

export interface AppShellLinkProps {
  itemId: string;
  href: string;
  active: boolean;
  onActivate?: () => void;
  children: ReactNode;
}

export interface AdaptiveAppShellProps {
  navigation: readonly NavigationItem[];
  activeId: string;
  children: ReactNode;
  brand?: ReactNode;
  copy?: AppShellCopy;
  presentationOverride?: Presentation;
  onNavigate?: (itemId: string) => void;
  linkComponent?: ComponentType<AppShellLinkProps>;
}

function DefaultLink({ href, active, onActivate, children }: AppShellLinkProps) {
  return (
    <a
      className="adaptive-link"
      href={href}
      aria-current={active ? "page" : undefined}
      onClick={onActivate}
    >
      {children}
    </a>
  );
}

function focusIfVisible(element: HTMLElement | null) {
  if (!element || element.closest("[hidden], [inert]")) return false;
  element.focus();
  return true;
}

function focusables(root: HTMLElement) {
  return Array.from(
    root.querySelectorAll<HTMLElement>(
      'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])',
    ),
  ).filter((element) => element.getClientRects().length > 0);
}

export function getTouchNavigation(navigation: readonly NavigationItem[]) {
  const permitted = navigation.filter((item) => item.permission !== false);
  const primary = permitted.filter((item) => item.priority !== "more");
  const explicitMore = permitted.filter((item) => item.priority === "more");
  const hasOverflow =
    explicitMore.length > 0 || primary.length > TOUCH_NAVIGATION_SLOTS;
  const visiblePrimary = primary.slice(
    0,
    hasOverflow ? TOUCH_NAVIGATION_SLOTS - 1 : TOUCH_NAVIGATION_SLOTS,
  );
  return {
    permitted,
    primary: visiblePrimary,
    overflow: [...primary.slice(visiblePrimary.length), ...explicitMore],
    hasOverflow,
  };
}

export function AdaptiveAppShell({
  navigation,
  activeId,
  children,
  brand = "Application",
  copy,
  presentationOverride,
  onNavigate,
  linkComponent: Link = DefaultLink,
}: AdaptiveAppShellProps) {
  const presentation = useAdaptivePresentation(presentationOverride);
  const [moreOpen, setMoreOpen] = useState(false);
  const moreSheetId = useId();
  const moreTitleId = useId();
  const sidebarRef = useRef<HTMLElement>(null);
  const touchBarRef = useRef<HTMLElement>(null);
  const mainRef = useRef<HTMLElement>(null);
  const moreSheetRef = useRef<HTMLElement>(null);
  const moreTriggerRef = useRef<HTMLButtonElement>(null);
  const previousPresentation = useRef<Presentation | null>(null);
  const returnFocus = useRef<HTMLElement | null>(null);
  const labels = {
    primaryNavigation: copy?.primaryNavigation ?? "Primary navigation",
    more: copy?.more ?? "More",
    moreNavigation: copy?.moreNavigation ?? "More navigation",
    closeMoreNavigation: copy?.closeMoreNavigation ?? "Close more navigation",
    pendingPresentation: copy?.pendingPresentation ?? "Loading presentation",
  };
  const { permitted, primary, overflow, hasOverflow } =
    getTouchNavigation(navigation);
  const isDesktop = presentation === "desktop";
  const isTouch = presentation === "touch";
  const activeOverflow = overflow.some((item) => item.id === activeId);

  useEffect(() => {
    const changed =
      previousPresentation.current !== null &&
      previousPresentation.current !== presentation;
    if (changed && moreOpen) {
      setMoreOpen(false);
      focusIfVisible(isDesktop ? sidebarRef.current : touchBarRef.current);
    }
    previousPresentation.current = presentation;
  }, [isDesktop, moreOpen, presentation]);

  useEffect(() => {
    if (!moreOpen || !isTouch || !hasOverflow) return;
    const sheet = moreSheetRef.current;
    const first = sheet && focusables(sheet)[0];
    first?.focus();
    const previousOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        event.preventDefault();
        returnFocus.current = moreTriggerRef.current;
        setMoreOpen(false);
        return;
      }
      if (event.key !== "Tab" || !sheet) return;
      const elements = focusables(sheet);
      if (!elements.length) return;
      const firstElement = elements[0];
      const lastElement = elements[elements.length - 1];
      if (event.shiftKey && document.activeElement === firstElement) {
        event.preventDefault();
        lastElement.focus();
      } else if (!event.shiftKey && document.activeElement === lastElement) {
        event.preventDefault();
        firstElement.focus();
      }
    };
    document.addEventListener("keydown", onKeyDown);
    return () => {
      document.removeEventListener("keydown", onKeyDown);
      document.body.style.overflow = previousOverflow;
      const target = returnFocus.current;
      returnFocus.current = null;
      if (target) focusIfVisible(target);
    };
  }, [hasOverflow, isTouch, moreOpen]);

  const renderLink = (item: NavigationItem) => (
    <Link
      key={item.id}
      itemId={item.id}
      href={item.href}
      active={item.id === activeId}
      onActivate={() => onNavigate?.(item.id)}
    >
      <span aria-hidden="true">{item.icon ?? "•"}</span>
      <span>{item.label}</span>
    </Link>
  );

  return (
    <div
      className="adaptive-shell"
      data-presentation={presentation ?? "pending"}
      aria-busy={presentation === null}
      aria-label={presentation === null ? labels.pendingPresentation : undefined}
    >
      <aside
        ref={sidebarRef}
        className="adaptive-shell__sidebar"
        aria-label={labels.primaryNavigation}
        hidden={!isDesktop}
        aria-hidden={!isDesktop}
        inert={!isDesktop}
        tabIndex={-1}
      >
        <div>{brand}</div>
        <nav aria-label={labels.primaryNavigation}>
          <ul>{permitted.map(renderLink)}</ul>
        </nav>
      </aside>
      <main ref={mainRef} className="adaptive-shell__main">
        <header
          ref={touchBarRef}
          className="adaptive-shell__touchbar"
          hidden={!isTouch}
          aria-hidden={!isTouch}
          inert={!isTouch}
          tabIndex={-1}
        >
          <strong>{brand}</strong>
          <span className="adaptive-muted">
            {permitted.find((item) => item.id === activeId)?.label ?? "Page"}
          </span>
        </header>
        <div className="adaptive-shell__content">{children}</div>
        <div
          className="adaptive-shell__bottom"
          hidden={!isTouch}
          aria-hidden={!isTouch}
          inert={!isTouch}
        >
          <nav aria-label={labels.primaryNavigation}>
            <ul>
              {primary.map((item) => <li key={item.id}>{renderLink(item)}</li>)}
              {hasOverflow && (
                <li>
                  <Button
                    ref={moreTriggerRef}
                    className="adaptive-button"
                    type="button"
                    aria-haspopup="dialog"
                    aria-controls={moreSheetId}
                    aria-expanded={moreOpen}
                    aria-current={activeOverflow ? "page" : undefined}
                    onClick={() => {
                      returnFocus.current = moreTriggerRef.current;
                      setMoreOpen(true);
                    }}
                  >
                    <span aria-hidden="true">⋯</span>
                    <span>{labels.more}</span>
                  </Button>
                </li>
              )}
            </ul>
          </nav>
        </div>
        <Sheet open={moreOpen && isTouch} onOpenChange={setMoreOpen}>
          {moreOpen && isTouch && hasOverflow && (
            <div className="adaptive-overlay">
              <Button
                className="adaptive-overlay__dismiss"
                type="button"
                aria-label={labels.closeMoreNavigation}
                onClick={() => setMoreOpen(false)}
              />
              <section
                ref={moreSheetRef}
                id={moreSheetId}
                className="adaptive-sheet"
                role="dialog"
                aria-modal="true"
                aria-labelledby={moreTitleId}
              >
                <div className="adaptive-sheet__header">
                  <h2 id={moreTitleId}>{labels.moreNavigation}</h2>
                  <Button
                    className="adaptive-button"
                    type="button"
                    aria-label={labels.closeMoreNavigation}
                    onClick={() => setMoreOpen(false)}
                  >
                    ×
                  </Button>
                </div>
                <ul className="adaptive-list">
                  {overflow.map((item) => (
                    <li key={item.id}>
                      <Link
                        itemId={item.id}
                        href={item.href}
                        active={item.id === activeId}
                        onActivate={() => {
                          setMoreOpen(false);
                          onNavigate?.(item.id);
                        }}
                      >
                        <span aria-hidden="true">{item.icon ?? "•"}</span>
                        <span>{item.label}</span>
                      </Link>
                    </li>
                  ))}
                </ul>
              </section>
            </div>
          )}
        </Sheet>
      </main>
    </div>
  );
}


// src/adaptive-artifact.css
.adaptive-surface { color: var(--foreground); background: var(--background); }
.adaptive-muted { color: var(--muted-foreground); }
.adaptive-border { border-color: var(--border); }
.adaptive-button { min-height: 2.5rem; border: 1px solid var(--border); border-radius: .5rem; background: var(--background); color: var(--foreground); padding: .55rem .8rem; cursor: pointer; }
.adaptive-button:hover { background: var(--muted); }
.adaptive-button:focus-visible, .adaptive-link:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.adaptive-shell { min-height: 100%; display: grid; grid-template-columns: 16rem minmax(0, 1fr); background: var(--background); color: var(--foreground); }
.adaptive-shell[data-presentation="touch"] { display: block; padding-bottom: calc(4.75rem + env(safe-area-inset-bottom)); }
.adaptive-shell__sidebar { border-right: 1px solid var(--border); padding: 1rem; }
.adaptive-shell__sidebar nav ul, .adaptive-shell__bottom nav ul { list-style: none; margin: 1rem 0 0; padding: 0; display: grid; gap: .25rem; }
.adaptive-link { display: flex; align-items: center; gap: .6rem; border-radius: .5rem; color: inherit; text-decoration: none; padding: .65rem .7rem; }
.adaptive-link[aria-current="page"] { background: var(--accent); font-weight: 650; }
.adaptive-shell__main { min-width: 0; }
.adaptive-shell__touchbar { display: flex; justify-content: space-between; align-items: center; gap: 1rem; border-bottom: 1px solid var(--border); padding: max(.8rem, env(safe-area-inset-top)) 1rem .8rem; }
.adaptive-shell__content { min-width: 0; padding: 1rem; }
.adaptive-shell__bottom { position: fixed; z-index: 10; right: 0; bottom: 0; left: 0; border-top: 1px solid var(--border); background: color-mix(in srgb, var(--background) 94%, transparent); padding: .35rem 1rem calc(.35rem + env(safe-area-inset-bottom)); }
.adaptive-shell__bottom nav ul { display: flex; justify-content: space-around; margin: 0; }
.adaptive-shell__bottom li { min-width: 4rem; }
.adaptive-shell__bottom .adaptive-link, .adaptive-shell__bottom .adaptive-button { width: 100%; justify-content: center; flex-direction: column; gap: .1rem; border: 0; background: transparent; padding: .35rem .25rem; font-size: .72rem; }
.adaptive-overlay { position: fixed; z-index: 20; inset: 0; background: rgb(0 0 0 / .35); }
.adaptive-sheet { position: absolute; right: 0; bottom: 0; left: 0; max-height: min(80vh, 42rem); overflow: auto; border: 1px solid var(--border); border-radius: 1rem 1rem 0 0; background: var(--background); padding: 1rem; padding-bottom: calc(1rem + env(safe-area-inset-bottom)); }
.adaptive-sheet--side { top: 0; right: 0; bottom: 0; left: auto; width: min(30rem, 92vw); max-height: none; border-radius: 1rem 0 0 1rem; padding-bottom: 1rem; }
.adaptive-sheet--full { top: 0; max-height: none; border-radius: 0; padding-top: max(1rem, env(safe-area-inset-top)); }
.adaptive-sheet__header { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
.adaptive-grid { display: grid; gap: 1rem; }
.adaptive-card { border: 1px solid var(--border); border-radius: .75rem; padding: 1rem; background: var(--card, var(--background)); }
.adaptive-table { width: 100%; border-collapse: collapse; }
.adaptive-table th, .adaptive-table td { border-bottom: 1px solid var(--border); padding: .75rem .5rem; text-align: left; vertical-align: top; }
.adaptive-table th { font-size: .8rem; color: var(--muted-foreground); font-weight: 650; }
.adaptive-field { display: grid; gap: .35rem; }
.adaptive-field + .adaptive-field { margin-top: .9rem; }
.adaptive-field input, .adaptive-field select { width: 100%; min-height: 2.5rem; border: 1px solid var(--border); border-radius: .45rem; background: var(--background); color: var(--foreground); padding: .5rem .65rem; }
.adaptive-list { list-style: none; margin: 0; padding: 0; display: grid; gap: .6rem; }
.adaptive-list > li { border-bottom: 1px solid var(--border); padding: .8rem 0; }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; } }


// src/use-adaptive-presentation.ts
import { useEffect, useState } from "react";

export const PRESENTATION_BREAKPOINT = 1024;
export type Presentation = "desktop" | "touch";

export function useAdaptivePresentation(
  override?: Presentation,
): Presentation | null {
  const [presentation, setPresentation] = useState<Presentation | null>(
    override ?? null,
  );

  useEffect(() => {
    if (override) {
      setPresentation(override);
      return;
    }

    const update = () => {
      setPresentation(
        window.innerWidth >= PRESENTATION_BREAKPOINT ? "desktop" : "touch",
      );
    };
    update();

    const query = window.matchMedia(
      `(min-width: ${PRESENTATION_BREAKPOINT}px)`,
    );
    const onChange = () => update();
    if (query.addEventListener) query.addEventListener("change", onChange);
    else query.addListener(onChange);
    window.addEventListener("resize", update);
    return () => {
      window.removeEventListener("resize", update);
      if (query.removeEventListener) query.removeEventListener("change", onChange);
      else query.removeListener(onChange);
    };
  }, [override]);

  return override ?? presentation;
}