Layout

Solid 2Unstable

Tabs on a product page

Switch between a piece's description, details and delivery, and the underline slides to the tab you chose.

Live demo

Loading the piece…

Reduced motion: The underline moves to its tab at once, and the panels still fade.

Why

An underline that blinks from one tab to the next tells you the tab changed. One that slides there also tells you which way you went, and the tabs read as one control rather than three buttons.

There's one indicator, drawn inside whichever tab is selected. Each tab renders it only while it's selected, all with the same layoutId, so when the selection changes it leaves one tab and arrives in the next, and Wardic animates it from the old tab's box to the new one's. The panels swap in an <AnimatePresence mode="wait">: the old one fades out before the new one fades in.

The code

examples/product-tabs/Demo.tsx
import { formatPrice } from "@wardic/example-storefront-shared/format"
import { photoOf, srcsetOf } from "@wardic/example-storefront-shared/images"
import type { Product } from "@wardic/example-storefront-shared/server"
import { AnimatePresence, wardic } from "@wardic/solid/unstable/v2"
import { createMemo, createSignal, For, Loading, Match, Show, Switch } from "solid-js"
import { product } from "~/lib/store.ts"

const TABS = ["Description", "Details", "Delivery"] as const
type Tab = (typeof TABS)[number]

// The panel that's going fades up and out, then the next fades up in (mode="wait").
const panel = {
  initial: { opacity: 0, y: 8 },
  animate: { opacity: 1, y: 0 },
  exit: { opacity: 0, y: -8 },
  transition: { type: "tween", duration: 0.18 },
} as const

export default function ProductTabs() {
  const piece = createMemo(() => product("lagoon-vase"))
  const [tab, setTab] = createSignal<Tab>("Description")
  const photo = photoOf("lagoon-vase")
  return (
    <Loading fallback={<p class="muted">Loading the piece…</p>}>
      <article class="product-page">
        <div class="photo">
          <img src={photo.small} srcset={srcsetOf(photo)} sizes="(max-width: 720px) 100vw, 420px" width={photo.width} height={photo.height} alt={piece().name} />
        </div>
        <div class="product-info">
          <h2 class="name">{piece().name}</h2>
          <p class="price">{formatPrice(piece().price)}</p>
          <div class="tabs" role="tablist" aria-label="About this piece">
            <For each={TABS}>
              {(name) => (
                <button class="tab" role="tab" aria-selected={String(tab() === name) as "true" | "false"} aria-controls="piece-panel" onClick={() => setTab(name)}>
                  {name}
                  {/* One indicator, under whichever tab is selected: its layoutId carries it from tab to tab. */}
                  <Show when={tab() === name}>
                    <wardic.span class="tab-indicator" layoutId="tab-indicator" />
                  </Show>
                </button>
              )}
            </For>
          </div>
          <div class="tab-panel" id="piece-panel" role="tabpanel" aria-label={tab()}>
            <AnimatePresence mode="wait" initial={false}>
              {/* keyed: each tab is a new child, so the last one leaves before it enters. */}
              <Show when={tab()} keyed>
                {(name) => (
                  <wardic.div {...panel}>
                    <PanelFor tab={name} piece={piece()} />
                  </wardic.div>
                )}
              </Show>
            </AnimatePresence>
          </div>
        </div>
      </article>
    </Loading>
  )
}

function PanelFor(props: { readonly tab: Tab; readonly piece: Product }) {
  return (
    <Switch>
      <Match when={props.tab === "Description"}>
        <p>{props.piece.description}</p>
      </Match>
      <Match when={props.tab === "Details"}>
        <ul class="details">
          <For each={props.piece.details}>{(detail) => <li>{detail}</li>}</For>
        </ul>
      </Match>
      <Match when={props.tab === "Delivery"}>
        <p>Ships in two to three working days, wrapped in recycled paper. Delivery is free over $100, and you can return a piece within 30 days, unused and in its box.</p>
      </Match>
    </Switch>
  )
}
examples/product-tabs/Demo.tsx
// Tabs on a product page, a server component: it starts the piece's read and hands its promise to
// the client part.
import { getProduct } from "~/lib/store.ts"
import { ProductTabs } from "./Client.tsx"

export default function Demo() {
  return <ProductTabs piece={getProduct("lagoon-vase")} />
}
examples/product-tabs/Client.tsx
"use client"
import { formatPrice } from "@wardic/example-storefront-shared/format"
import { photoOf, srcsetOf } from "@wardic/example-storefront-shared/images"
import type { Product } from "@wardic/example-storefront-shared/server"
import { AnimatePresence, motion } from "motion/react"
import { Suspense, use, useState } from "react"

const TABS = ["Description", "Details", "Delivery"] as const
type Tab = (typeof TABS)[number]

// The panel that's going fades up and out, then the next fades up in (mode="wait").
const panel = {
  initial: { opacity: 0, y: 8 },
  animate: { opacity: 1, y: 0 },
  exit: { opacity: 0, y: -8 },
  transition: { type: "tween", duration: 0.18 },
} as const

export function ProductTabs({ piece }: { readonly piece: Promise<Product> }) {
  return (
    <Suspense fallback={<p className="muted">Loading the piece…</p>}>
      <ProductPage piece={piece} />
    </Suspense>
  )
}

function ProductPage(props: { readonly piece: Promise<Product> }) {
  const piece = use(props.piece)
  const [tab, setTab] = useState<Tab>("Description")
  const photo = photoOf("lagoon-vase")
  return (
    <article className="product-page">
      <div className="photo">
        <img src={photo.small} srcSet={srcsetOf(photo)} sizes="(max-width: 720px) 100vw, 420px" width={photo.width} height={photo.height} alt={piece.name} />
      </div>
      <div className="product-info">
        <h2 className="name">{piece.name}</h2>
        <p className="price">{formatPrice(piece.price)}</p>
        <div className="tabs" role="tablist" aria-label="About this piece">
          {TABS.map((name) => (
            <button key={name} className="tab" role="tab" aria-selected={tab === name} aria-controls="piece-panel" onClick={() => setTab(name)}>
              {name}
              {/* One indicator, under whichever tab is selected: its layoutId carries it from tab to tab. */}
              {tab === name && <motion.span className="tab-indicator" layoutId="tab-indicator" />}
            </button>
          ))}
        </div>
        <div className="tab-panel" id="piece-panel" role="tabpanel" aria-label={tab}>
          <AnimatePresence mode="wait" initial={false}>
            {/* A new key for each tab: the last panel leaves before the next enters. */}
            <motion.div key={tab} {...panel}>
              <PanelFor tab={tab} piece={piece} />
            </motion.div>
          </AnimatePresence>
        </div>
      </div>
    </article>
  )
}

function PanelFor({ tab, piece }: { readonly tab: Tab; readonly piece: Product }) {
  if (tab === "Details")
    return (
      <ul className="details">
        {piece.details.map((detail) => (
          <li key={detail}>{detail}</li>
        ))}
      </ul>
    )
  if (tab === "Delivery") return <p>Ships in two to three working days, wrapped in recycled paper. Delivery is free over $100, and you can return a piece within 30 days, unused and in its box.</p>
  return <p>{piece.description}</p>
}
Run Motion's version →