# Tabs Animated Height Content — React Tabs Component

> Tabs inside a card that springs to the height of each panel, so short and long content swap without a layout jump.

- Category: [Tabs](https://pixelpapa.com/components/tabs.md)
- Tags: tabs, height, resize, spring, settings
- Dependencies: motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/tabs/tabs-animated-height-content

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/tabs-animated-height-content.json"
```

The file is written to `components/pixelpapa/tabs-animated-height-content.tsx`. To install manually, run `npm install motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";

export type HeightTab = { value: string; label: string; content: ReactNode };

type AnimatedHeightTabsProps = { tabs: HeightTab[]; defaultValue?: string; className?: string };

export function AnimatedHeightTabs({ tabs, defaultValue, className }: AnimatedHeightTabsProps) {
  const id = useId();
  const reduce = useReducedMotion();
  const inner = useRef<HTMLDivElement>(null);
  const [index, setIndex] = useState(() => Math.max(0, tabs.findIndex((t) => t.value === defaultValue)));
  const [height, setHeight] = useState<number | "auto">("auto");

  // Track the natural height of whatever panel is mounted; the wrapper springs to it.
  useEffect(() => {
    const el = inner.current;
    if (!el) return;
    const ro = new ResizeObserver(() => setHeight(el.offsetHeight));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    let next = index;
    if (e.key === "ArrowRight") next = (index + 1) % tabs.length;
    else if (e.key === "ArrowLeft") next = (index - 1 + tabs.length) % tabs.length;
    else return;
    e.preventDefault();
    setIndex(next);
    e.currentTarget.querySelectorAll<HTMLElement>('[role="tab"]')[next]?.focus();
  }

  return (
    <div className={cn("w-full overflow-hidden rounded-2xl border border-border bg-card text-card-foreground shadow-sm", className)}>
      <div role="tablist" onKeyDown={onKeyDown} className="flex gap-1 border-b border-border bg-muted/50 p-1.5">
        {tabs.map((tab, i) => (
          <button
            key={tab.value}
            role="tab"
            id={`${id}-tab-${i}`}
            aria-selected={i === index}
            aria-controls={`${id}-panel`}
            tabIndex={i === index ? 0 : -1}
            onClick={() => setIndex(i)}
            className={cn(
              "relative flex-1 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors duration-200",
              i === index ? "text-foreground" : "text-muted-foreground hover:text-foreground",
            )}
          >
            {i === index && (
              <motion.span
                layoutId={`tabs-animated-height-content-${id}`}
                className="absolute inset-0 rounded-lg bg-background shadow-sm ring-1 ring-border/70"
                transition={{ type: "spring", stiffness: 420, damping: 34 }}
              />
            )}
            <span className="relative">{tab.label}</span>
          </button>
        ))}
      </div>

      <motion.div
        initial={false}
        animate={{ height }}
        transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 300, damping: 30 }}
        className="relative overflow-hidden"
      >
        <div ref={inner}>
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.div
              key={tabs[index].value}
              role="tabpanel"
              id={`${id}-panel`}
              aria-labelledby={`${id}-tab-${index}`}
              initial={{ opacity: 0, y: 12, filter: "blur(4px)" }}
              animate={{ opacity: 1, y: 0, filter: "blur(0px)", transition: { delay: 0.06, duration: 0.25 } }}
              exit={{ opacity: 0, filter: "blur(4px)", transition: { duration: 0.12 } }}
              className="p-5"
            >
              {tabs[index].content}
            </motion.div>
          </AnimatePresence>
        </div>
      </motion.div>
    </div>
  );
}

function Field({ label, value, type = "text" }: { label: string; value: string; type?: string }) {
  const id = useId();
  return (
    <div>
      <label htmlFor={id} className="text-xs font-medium text-muted-foreground">
        {label}
      </label>
      <input
        id={id}
        type={type}
        defaultValue={value}
        className="mt-1.5 h-9 w-full rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring/40"
      />
    </div>
  );
}

function Switch({ label, hint, defaultOn = false }: { label: string; hint: string; defaultOn?: boolean }) {
  const [on, setOn] = useState(defaultOn);
  return (
    <div className="flex items-center justify-between gap-6 py-2.5">
      <span>
        <span className="block text-sm font-medium">{label}</span>
        <span className="block text-xs text-muted-foreground">{hint}</span>
      </span>
      <button
        role="switch"
        aria-checked={on}
        aria-label={label}
        onClick={() => setOn(!on)}
        className={cn("flex h-6 w-10 shrink-0 rounded-full p-0.5 transition-colors", on ? "justify-end bg-primary" : "bg-input")}
      >
        <motion.span layout transition={{ type: "spring", stiffness: 600, damping: 34 }} className="size-5 rounded-full bg-white shadow-sm" />
      </button>
    </div>
  );
}

const demoTabs: HeightTab[] = [
  {
    value: "profile",
    label: "Profile",
    content: (
      <div className="grid gap-4">
        <Field label="Display name" value="Ines Okafor" />
        <Field label="Email" value="ines@northwind.dev" type="email" />
      </div>
    ),
  },
  {
    value: "notifications",
    label: "Notifications",
    content: (
      <div className="divide-y divide-border">
        <Switch label="Mentions" hint="When someone @mentions you in a comment." defaultOn />
        <Switch label="Deploy failures" hint="Builds that fail on a production branch." defaultOn />
        <Switch label="Weekly digest" hint="A Monday summary of what shipped." />
        <Switch label="Product news" hint="Occasional updates about new features." />
      </div>
    ),
  },
  {
    value: "security",
    label: "Security",
    content: (
      <div className="grid gap-4">
        <Field label="Current password" value="" type="password" />
        <Field label="New password" value="" type="password" />
        <Switch label="Two-factor authentication" hint="Ask for a code from your authenticator app." defaultOn />
      </div>
    ),
  },
  {
    value: "danger",
    label: "Danger",
    content: (
      <div className="flex items-center justify-between gap-6">
        <p className="text-sm text-muted-foreground">Deleting your account removes 3 projects and cannot be undone.</p>
        <button className="h-9 shrink-0 rounded-lg border border-destructive/30 bg-destructive/8 px-3.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/15">
          Delete account
        </button>
      </div>
    ),
  },
];

export default function TabsAnimatedHeightContentDemo() {
  return <AnimatedHeightTabs tabs={demoTabs} defaultValue="notifications" className="max-w-md" />;
}
```

## AI prompt

```text
Add the "Tabs Animated Height Content" component from PixelPapa UI to this project.

What it is: Tabs inside a card that springs to the height of each panel, so short and long content swap without a layout jump.
Category: Tabs. Depends on: motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/tabs-animated-height-content.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/tabs-animated-height-content.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/tabs-animated-height-content" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More tabs

- [Tabs Folder](https://pixelpapa.com/components/tabs/tabs-folder.md): Browser-style tabs with scooped corners: the active shape slides between tabs, and tabs can be closed or added with layout springs.
- [Tabs Scrollable Overflow](https://pixelpapa.com/components/tabs/tabs-scrollable-overflow.md) (Pro): A tab strip that scrolls when it runs out of room: edge fades and arrow buttons appear only on the side that has more, and the active tab centres itself.
- [Tabs Segmented Icons](https://pixelpapa.com/components/tabs/tabs-segmented-icons.md): An icon segmented control whose active segment unfolds its label, driving a task view where the same cards re-flow between list, board, grid and timeline.
- [Tabs Sliding Pill](https://pixelpapa.com/components/tabs/tabs-sliding-pill.md) (Pro): A pill glides between tabs on a shared-layout spring while the panel slides in from the side you moved toward.
- [Tabs Underline Spring](https://pixelpapa.com/components/tabs/tabs-underline-spring.md) (Pro): An underline that stretches toward the next tab before its tail catches up, with a hover highlight that follows the pointer.
- [Tabs Vertical With Preview](https://pixelpapa.com/components/tabs/tabs-vertical-with-preview.md) (Pro): Vertical feature tabs that auto-advance with a filling rail and swap a drawn product preview, sliding up or down by direction.
