caliper uidocs

Card switcher

Free

An app switcher for open pages. Cards fan out in depth by recency: the page you are on is in front and each step back is one page older. Flick a card up to close it, press the front card to open it full size.

Installs components/ui/card-switcher.tsxDepends on @radix-ui/react-slot, lucide-reactMotion none in component source
pnpm dlx shadcn@latest add @caliper/card-switcher
Left and right arrows switch, Enter opens, Delete closes, Escape returns from a page.
5 open · 3 of 5

When to use #

Use it for

  • Mobile-style multitasking: open documents, tabs or conversations.
  • Reviewing a handful of recent items and dropping the ones you are done with.

Skip it when

  • Long lists; use a list with search.
  • Anything that needs all items visible at once.

Decisions #

Why it is built this way, so you know what to keep when you change your copy.

Behaviour

Every gesture has a key and a button
Arrow keys move, Enter opens, Delete closes, Escape returns. Previous, Next and Open are plain buttons and a count reads, for example, 4 open · 2 of 4.
Closing is yours to confirm
The card flicks away, then onClose tells you which page it was. You remove it from pages, and the rest close the gap.

Usage

Empty can offer a way back
Pass onReopen and the empty switcher shows Reopen all.

Installation #

terminal
pnpm dlx shadcn@latest add @caliper/card-switcher

Adds its 7 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.

Usage #

This is the code behind the preview above.

example.tsx
import { useState } from "react"
import { CardSwitcher, type SwitcherPage } from "@/components/ui/card-switcher"

const openPages: SwitcherPage[] = [
  { id: "inbox", title: "Inbox", url: "mail.halden.app", content: "Twelve unread messages. The newest is from Priya about Thursday's failover drill." },
  { id: "calendar", title: "Calendar", url: "cal.halden.app", content: "Four events today. Next up: design review at 14:00." },
  { id: "docs", title: "Docs", url: "docs.halden.app", content: "Runbook: database failover. Last edited by Priya N. on October 14." },
  { id: "board", title: "Board", url: "board.halden.app", content: "Sprint 42: 9 of 14 cards done, 2 blocked." },
  { id: "reports", title: "Reports", url: "reports.halden.app", content: "Weekly signups are up 12 percent on last week." },
];

export function CardSwitcherDemo() {
  const [pages, setPages] = useState(openPages);
  const [current, setCurrent] = useState("docs");
  return (
    <div className="w-full max-w-xl">
      <CardSwitcher
        label="Open pages"
        pages={pages}
        current={current}
        onCurrentChange={setCurrent}
        onClose={(id) => setPages((cur) => cur.filter((page) => page.id !== id))}
        onReopen={() => {
          setPages(openPages);
          setCurrent("inbox");
        }}
      />
    </div>
  );
}

API reference #

CardSwitcher

component
PropTypeDefaultDescription
label *string—Accessible name of the switcher, for example "Open pages".
pages *readonly SwitcherPage[]—
currentstring—The id of the front card. Controlled when set; defaults to the last page.
defaultCurrentstring—
onCurrentChange((id: string) => void)—
onClose((id: string) => void)—Called with a page's id after its card has flicked away. Remove the page from `pages`.
onOpen((id: string) => void)—
onBack(() => void)—Called when the full-size page is dismissed.
onReopen(() => void)—When set, the empty switcher offers a "Reopen all" button that calls it.
statusDepthStatus"ready"
messagesDepthMessages—
onRetry(() => void)—
disabledboolean—
classNamestring—

Accessibility #

It exposes group and region semantics, with aria-label, aria-describedby, aria-hidden and aria-live kept in step with state. Changes that matter are announced through a live region.

KeyBehaviour
EscapeCloses or cancels, then returns focus to where it came from.
←Moves to the previous item or value.
→Moves to the next item or value.
DeleteRemoves the next character or the selected item.

Motion #

It declares no animation in its component source. Any transition comes from its stylesheet, which uses the duration tokens that collapse to instant under prefers-reduced-motion.