Skip to content

Empty & error states

The same persistent sidebar/header shell as the other App screens patterns, with a Tabs control that swaps the content card between the three states a real list screen has to handle: a Skeleton that mirrors the eventual row layout, an EmptyState with a call to action, and an inline Alert with retry — so the screen is never a confusing blank.

empty-and-error-states.tsx

Built with

How it’s built

  1. 01One state value drives which of the three bodies renders — Tabs is just a real, accessible way to switch it, not three separate routes.
  2. 02The Skeleton rows mirror the shape of the content they'll be replaced by (an avatar circle plus two text lines) so the layout doesn't jump once real data arrives.
  3. 03The Error Alert's Retry button flips state back to "loading" — a stand-in for the real refetch a production screen would trigger.

Source

Three files · copy & paste

Styling: this source uses Tailwind utility classes and this site’s colour tokens (for example text-ink, bg-paper and border-line). The oks-ui components render without them; swap those classes for your own.

empty-and-error-states.tsx
import { useState } from "react";
import { Alert } from "oks-ui/alert";
import { Button } from "oks-ui/button";
import { EmptyState } from "oks-ui/empty-state";
import type { NavItemData } from "oks-ui/nav";
import { PageTitle } from "oks-ui/page-title";
import { Skeleton } from "oks-ui/skeleton";
import { Tab, Tabs } from "oks-ui/tabs";
import { AppShell } from "./AppShell";
import {
  BriefcaseIcon,
  CreditCardIcon,
  GridIcon,
  InboxIcon,
  PlusIcon,
  ReceiptIcon,
  SettingsIcon,
  UsersIcon,
} from "./icons";

const NAV_ITEMS: NavItemData[] = [
  {
    key: "main",
    label: "Main menu",
    isSection: true,
    children: [
      { key: "dashboard", label: "Dashboard", icon: <GridIcon size={18} /> },
      { key: "projects", label: "Projects", icon: <BriefcaseIcon size={18} /> },
      { key: "invoices", label: "Invoices", icon: <ReceiptIcon size={18} /> },
      { key: "team", label: "Team", icon: <UsersIcon size={18} /> },
    ],
  },
  {
    key: "other",
    label: "Other",
    isSection: true,
    children: [
      { key: "billing", label: "Billing", icon: <CreditCardIcon size={18} /> },
      { key: "settings", label: "Settings", icon: <SettingsIcon size={18} /> },
    ],
  },
];

type ViewState = "loading" | "empty" | "error";

const ListRowSkeleton = () => {
  return (
    <div className="flex items-center gap-3 py-2.5">
      <Skeleton variant="circle" width={32} height={32} />
      <div className="flex flex-1 flex-col gap-1.5">
        <Skeleton variant="text" width="40%" />
        <Skeleton variant="text" width="65%" />
      </div>
    </div>
  );
};

const EmptyAndErrorStates = () => {
  const [state, setState] = useState<ViewState>("loading");

  return (
    <AppShell navItems={NAV_ITEMS} selectedNavKey="projects">
      <div className="flex shrink-0 flex-wrap items-center justify-between gap-3">
        <PageTitle
          as="h2"
          title="Projects"
          classNames={{ base: "flex-col items-start", title: "text-2xl font-bold tracking-tight text-ink" }}
        />
        <Button color="primary" radius="full" size="sm" startContent={<PlusIcon size={14} />}>
          New project
        </Button>
      </div>

      <Tabs
        aria-label="Preview a state"
        variant="underlined"
        selectedKey={state}
        onSelectionChange={(key) => setState(key as ViewState)}
        classNames={{ base: "shrink-0" }}
      >
        <Tab key="loading" title="Loading" />
        <Tab key="empty" title="Empty" />
        <Tab key="error" title="Error" />
      </Tabs>

      <div className="flex flex-1 flex-col rounded-[var(--r-lg)] bg-paper-raised p-6 shadow-[var(--shadow-sm)]">
        {state === "loading" ? (
          <div className="flex flex-col divide-y divide-line">
            <ListRowSkeleton />
            <ListRowSkeleton />
            <ListRowSkeleton />
          </div>
        ) : state === "empty" ? (
          <div className="flex flex-1 items-center justify-center">
            <EmptyState
              icon={<InboxIcon />}
              title="No projects yet"
              description="Create your first project to start tracking work with your team."
              actions={
                <Button color="primary" radius="full" startContent={<PlusIcon size={14} />}>
                  New project
                </Button>
              }
            />
          </div>
        ) : (
          <Alert
            color="danger"
            title="Couldn't load projects"
            description="Something went wrong on our end. Try again, or contact support if the problem continues."
            actions={
              <Button size="sm" variant="soft" color="danger" onClick={() => setState("loading")}>
                Retry
              </Button>
            }
          />
        )}
      </div>
    </AppShell>
  );
};

export default EmptyAndErrorStates;

It imports ./icons, so save this next to it:

icons.tsx
export const BriefcaseIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <rect x="2" y="7" width="20" height="14" rx="2" />
      <path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
      <path d="M2 13h20" />
    </svg>
  );
};

export const CreditCardIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <rect x="2" y="5" width="20" height="14" rx="2" />
      <path d="M2 10h20" />
    </svg>
  );
};

export const GridIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <rect x="3" y="3" width="7" height="7" rx="1.5" />
      <rect x="14" y="3" width="7" height="7" rx="1.5" />
      <rect x="3" y="14" width="7" height="7" rx="1.5" />
      <rect x="14" y="14" width="7" height="7" rx="1.5" />
    </svg>
  );
};

export const InboxIcon = ({ size = 48 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.6}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M4 12h4l2 3h4l2-3h4" />
      <path d="M5.5 6h13l2 6v7a1 1 0 0 1-1 1H4.5a1 1 0 0 1-1-1v-7z" />
    </svg>
  );
};

// Icons below this line were added for the AppShell sidebar (see
// src/templates/app-shell/AppShell.tsx) -- same "no icon library
// dependency" convention as everything above.

export const PlusIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M12 5v14M5 12h14" />
    </svg>
  );
};

export const ReceiptIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M5 3v18l2-1.5L9 21l2-1.5L13 21l2-1.5L17 21l2-1.5V3l-2 1.5L15 3l-2 1.5L11 3 9 4.5 7 3 5 4.5Z" />
      <path d="M8 8h8M8 12h8" />
    </svg>
  );
};

export const SettingsIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="3" />
      <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1.08-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1.08 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.26.42.7.7 1.19.83l.32.08c.6.15 1 .68 1 1.3v0" />
    </svg>
  );
};

export const UsersIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
      <circle cx="9" cy="7" r="4" />
      <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
      <path d="M16 3.13a4 4 0 0 1 0 7.75" />
    </svg>
  );
};

export const BellIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M6 8a6 6 0 0 1 12 0c0 4 1.5 5.5 2 6H4c.5-.5 2-2 2-6Z" />
      <path d="M10 20a2 2 0 0 0 4 0" />
    </svg>
  );
};

export const HelpCircleIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="9" />
      <path d="M9.5 9a2.5 2.5 0 0 1 4.8 1c0 1.5-2.3 1.8-2.3 3.5" />
      <path d="M12 17.5h.01" />
    </svg>
  );
};

// Icons below this line were added for Horizon's rebuild into a business
// back-office (Finance/Accounts/HR and Payroll/Peoples/Contracts) --
// same "no icon library dependency" convention as everything above.

export const SearchIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <circle cx="11" cy="11" r="7" />
      <path d="M21 21l-4.3-4.3" />
    </svg>
  );
};

It imports ./AppShell, so save this next to it:

AppShell.tsx
"use client";

import { useState, type ReactNode } from "react";
import { Avatar } from "oks-ui/avatar";
import { Button } from "oks-ui/button";
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from "oks-ui/dropdown";
import { FormFieldSet } from "oks-ui/form-field-set";
import { Nav } from "oks-ui/nav";
import type { NavItemData } from "oks-ui/nav";
import "oks-ui/avatar.css";
import "oks-ui/button.css";
import "oks-ui/dropdown.css";
import "oks-ui/form-field-set.css";
import "oks-ui/nav.css";
import { BellIcon, HelpCircleIcon, SearchIcon, SettingsIcon } from "./icons";

// Two overlapping chevrons — a standard "collapse sidebar" glyph, not a
// reproduction of any specific icon set's artwork.
const CollapseIcon = () => {
  return (
    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true">
      <path d="M6 3 2 8l4 5" />
      <path d="M12 3 8 8l4 5" />
    </svg>
  );
};

const LogOutIcon = ({ size = 16 }: { size?: number }) => {
  return (
    <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M6 14H3.5a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1H6" />
      <path d="M10.5 11 14 8l-3.5-3" />
      <path d="M14 8H6" />
    </svg>
  );
};

export type AppShellProps = {
  /** The sidebar tree — differs per pattern to match its app context (a Kanban tool's nav reads differently from an invoicing tool's). */
  navItems: NavItemData[];
  /** Omitted when the pattern being shown isn't one of the permanent nav rows (e.g. a Settings page reached from the account menu, not the sidebar itself). */
  selectedNavKey?: string;
  /** Lets a pattern make its own Nav row genuinely clickable (analytics-dashboard highlights whichever item — or account-menu action — was clicked last) instead of a fixed selectedNavKey. */
  onSelectedNavKeyChange?: (key: string) => void;
  /** Left side of the header, after the collapse toggle. Defaults to a plain search field; command-palette-shell overrides this with its ⌘K trigger button instead. */
  headerLeft?: ReactNode;
  /** Right side of the header, after the standard bell. Defaults to nothing extra. */
  headerExtra?: ReactNode;
  /** The scrollable main content (everything below the header). */
  children: ReactNode;
  /** Extra classes for the scrollable content wrapper — e.g. to drop the default padding for a pattern that wants edge-to-edge content. */
  contentClassName?: string;
};

/**
 * The one persistent sidebar/header app shell every "App screens" pattern on
 * this site shares. Pulled out of each individual pattern file after the
 * per-file copies drifted (one ended up with a different account name than
 * the rest) — the account identity (Omkar Sahu) and the structural chrome
 * (collapse toggle, Nav wiring, account Dropdown, bell) now live in exactly
 * one place, while each pattern still supplies its own contextual nav items
 * and can override the header's search slot when it needs to (the command
 * palette trigger, for instance).
 */
export const AppShell = ({
  navItems,
  selectedNavKey,
  onSelectedNavKeyChange,
  headerLeft,
  headerExtra,
  children,
  contentClassName,
}: AppShellProps) => {
  const [collapsed, setCollapsed] = useState(false);

  return (
    // @container has to live on its OWN wrapper, not the element whose
    // height is conditioned by @3xl: — an element can't query its own
    // container for its own styling (circular per the CSS Containment
    // spec), so @3xl:h-[46rem] would silently be evaluated against the
    // wrong (ancestor) container and never apply.
    <div className="@container w-full">
      <div className="flex h-[32rem] w-full overflow-hidden bg-paper @3xl:h-[46rem]">
        <aside
          role="none"
          className={`hidden h-full shrink-0 flex-col gap-6 overflow-y-auto overflow-x-hidden border-r border-line bg-paper-raised p-4 @3xl:flex ${collapsed ? "w-[4.5rem]" : "w-60"}`}
        >
          <div className="flex items-center px-1">
            {/* Two files, swapped by theme — logo.svg carries dark ink and would
                vanish against the dark sidebar. eslint-disable: fixed static
                assets already sized for this slot; next/image's runtime cost
                isn't worth it here. */}
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img src="/logo.svg" alt="oks-ui" className="oks-logo-for-light h-8 w-8 shrink-0" />
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img src="/logo-light.svg" alt="oks-ui" className="oks-logo-for-dark h-8 w-8 shrink-0" />
          </div>

          <Nav
            aria-label="Main"
            items={navItems}
            selectedKey={selectedNavKey}
            onItemSelect={onSelectedNavKeyChange ? (item) => onSelectedNavKeyChange(item.key) : undefined}
            isCollapsed={collapsed}
            classNames={{ base: "w-full", sectionTitle: collapsed ? "sr-only" : undefined }}
            renderItem={({ isActive, content, props }) => (
              <button type="button" {...props} className={`${props.className} flex items-center gap-1.5`}>
                {!collapsed ? (
                  <span
                    aria-hidden="true"
                    className="h-1.5 w-1.5 shrink-0 rounded-full"
                    style={{ background: isActive ? "var(--oks-color-primary-600)" : "transparent" }}
                  />
                ) : null}
                {content}
              </button>
            )}
          />

          <Dropdown placement="top-start" className="mt-auto w-full" classNames={{ content: "block w-full" }}>
            <DropdownTrigger>
              <Button
                variant="ghost"
                fullWidth
                className={`gap-2.5 p-2 ${collapsed ? "justify-center" : "justify-start"}`}
              >
                <Avatar src="/avatar-omkar.jpg" name="Omkar Sahu" size="sm" radius="full" />
                {!collapsed ? (
                  <span className="min-w-0 flex-1 text-left">
                    <span className="block truncate text-[0.85rem] font-medium text-ink">Omkar Sahu</span>
                    <span className="block truncate text-[0.75rem] text-ink-muted">me@oks-ui.com</span>
                  </span>
                ) : null}
              </Button>
            </DropdownTrigger>
            <DropdownMenu
              aria-label="Account menu"
              onAction={onSelectedNavKeyChange ? (key) => onSelectedNavKeyChange(String(key)) : undefined}
            >
              <DropdownItem key="settings" startContent={<SettingsIcon size={16} />}>
                Settings
              </DropdownItem>
              <DropdownItem key="help" startContent={<HelpCircleIcon size={16} />} showDivider>
                Help desk
              </DropdownItem>
              <DropdownItem key="logout" startContent={<LogOutIcon size={16} />} classNames={{ title: "text-[color:var(--oks-color-danger-700)]" }}>
                Log out
              </DropdownItem>
            </DropdownMenu>
          </Dropdown>
        </aside>

        <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
          <header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-line px-5 py-3.5 @3xl:px-8">
            <div className="flex min-w-0 flex-1 items-center gap-3">
              <Button
                isIconOnly
                variant="ghost"
                size="sm"
                onClick={() => setCollapsed((c) => !c)}
                aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
                className="hidden @3xl:flex"
              >
                <CollapseIcon />
              </Button>
              {headerLeft ?? (
                <FormFieldSet
                  type="search"
                  name="headerSearch"
                  label="Search"
                  placeholder="Search"
                  startIcon={<SearchIcon size={16} />}
                  classNames={{ base: "w-full max-w-xs", label: "sr-only", control: "border-transparent bg-paper-sunken" }}
                />
              )}
            </div>
            <div className="flex items-center gap-3">
              {headerExtra}
              <Button isIconOnly aria-label="Notifications" variant="ghost" radius="full" size="sm" className="relative">
                <BellIcon size={18} />
                <span aria-hidden="true" className="absolute right-2 top-2 h-1.5 w-1.5 rounded-full" style={{ background: "var(--oks-color-danger-500)" }} />
              </Button>
              <Avatar src="/avatar-omkar.jpg" name="Omkar Sahu" size="sm" radius="full" />
            </div>
          </header>

          <div className={contentClassName ?? "flex flex-1 flex-col gap-5 overflow-y-auto p-5 @3xl:p-8"}>{children}</div>
        </div>
      </div>
    </div>
  );
};