Skip to content
txbrendan/ui

Panel Switch

beta

Switches what a box shows for a new tab, filter, or item. The box holds still and glides to its new height.

Preview

Pick a tab. The box holds still and glides to each height.

Three items are open

  • One is due this week.

Step through the items with Previous and Next. Back to first skips the motion.

First item

1 of 5

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/panel-switch

Use it for

  • A box that stays on screen while its contents change: a tab's panel, a filtered list, or the item picked in a list.
  • Give each set of contents its own value. A new value switches the box. New children under the same value update in place.
  • Keep buttons that act on what the box shows, like Approve or Next, outside it, so the one you press never slides away.
  • Use glideFrom for a box inside that grows on its own, like a list that shows more rows. It glides on the same curve.

Avoid it for

  • A new page, a popup that opens, or a box that just appeared. Those keep their own motion.
  • New cards arriving in a grid. Let each one fade up where it lands.
  • Words on a button that change. Button already morphs its own words.

Props

  • value

    What the box shows. A new value switches the contents.

    string

  • order

    Where the value sits in its list. A later one slides in from the right and an earlier one from the left, mirrored in right-to-left pages.

    number · default 0

  • effect

    slide moves the new contents in from the side they come from. fade changes them in place, like a question that turns into its answer. Both glide the box to its new height. instant shows the change at once, with no motion.

    "slide" | "fade" | "instant" · default "slide"

  • children

    What the box shows for this value.

    ReactNode

  • glideFrom

    Helper. Glides a box that just grew or shrank from the height it had, on the switch's curve. Read its height before the change, and call it after.

    (element: HTMLElement, from: number) => void

Accessibility

  • The leaving contents are hidden from screen readers and cannot take focus or a click.
  • Focus inside the leaving contents goes to the tab that picked the new ones, or else into the new ones. With nothing there to take it, the box holds it, out of the Tab order. It is never dropped to the page.
  • Reduced motion keeps the fade, and drops the slide and the height glide.
  • Tell screen readers what changed, like a count of results, in a live region beside it.

Examples

Switch what a box shows by value

<PanelSwitch value={tab}>
  <p>{tab}</p>
</PanelSwitch>

Switch a panel's contents when a tab is picked

const [tab, setTab] = useState("overview");

<>
  <Tabs value={tab} onValueChange={setTab}>
    <TabsList aria-label="Sections">
      <TabsTab value="overview">Overview</TabsTab>
      <TabsTab value="activity">Activity</TabsTab>
    </TabsList>
  </Tabs>
  <PanelSwitch value={tab} order={tab === "overview" ? 0 : 1}>
    {tab === "overview" ? <p>Three items are open.</p> : <p>Two items changed today.</p>}
  </PanelSwitch>
</>

Change a question into its answer in place

<PanelSwitch value={picked ? "done" : "pick"} effect="fade">
  {picked ? <p>Owner: {picked}</p> : <button type="button" onClick={() => setPicked("Tom Hale")}>Pick an owner</button>}
</PanelSwitch>

Show a change at once when a link lands on hidden contents

<PanelSwitch value={item} order={index} effect={fromLink ? "instant" : "slide"}>
  <p>{item}</p>
</PanelSwitch>

Glide a list inside the box as it shows more rows

const rows = ["First", "Second", "Third", "Fourth", "Fifth"];
const list = useRef<HTMLUListElement>(null);
const before = useRef(0);
const [open, setOpen] = useState(false);

useLayoutEffect(() => {
  if (open && list.current) glideFrom(list.current, before.current);
}, [open]);

<>
  <ul ref={list}>
    {(open ? rows : rows.slice(0, 3)).map((row) => (
      <li key={row}>{row}</li>
    ))}
  </ul>
  <button
    type="button"
    onClick={() => {
      before.current = list.current?.offsetHeight ?? 0;
      setOpen(true);
    }}
  >
    Show more
  </button>
</>