feat(ui): add Tabs content transition

- Animate tab panels when switching active content
- Preserve reduced-motion behavior
- Cover shared Tabs motion with focused unit tests
This commit is contained in:
Alan Buscaglia committed 2026-06-05 14:49:09 +02:00
1 parent 8fb59682d5
commit 0e2bb99f02
2 files changed
+55 -6

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
describe("Tabs", () => {
it("animates tab content when switching between tabs", async () => {
// Given - A tabs group with two content panels
const user = userEvent.setup();
render(
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="events">Events</TabsTrigger>
</TabsList>
<TabsContent value="overview">Overview content</TabsContent>
<TabsContent value="events">Events content</TabsContent>
</Tabs>,
);
// When - The user switches to another tab
await user.click(screen.getByRole("tab", { name: /events/i }));
const eventsPanel = screen.getByText("Events content");
// Then - The newly active content keeps the motion-ready content element
expect(eventsPanel).toHaveAttribute("data-slot", "tabs-content");
expect(eventsPanel).toHaveClass(
"will-change-transform",
"motion-reduce:transform-none",
);
expect(eventsPanel).toHaveAttribute("data-state", "active");
});
});
+20 -6
View File
@@ -1,6 +1,7 @@
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { motion, useReducedMotion } from "framer-motion";
import type { ComponentProps, ReactNode } from "react";
import {
@@ -30,7 +31,7 @@ const TRIGGER_STYLES = {
* Content component styles
*/
const CONTENT_STYLES =
"mt-2 focus-visible:rounded-md focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-ring/50" as const;
"mt-2 will-change-transform motion-reduce:transform-none focus-visible:rounded-md focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-ring/50" as const;
/**
* Build trigger className by combining style parts
@@ -122,14 +123,27 @@ function TabsTrigger({
function TabsContent({
className,
children,
...props
}: ComponentProps<typeof TabsPrimitive.Content>) {
const shouldReduceMotion = useReducedMotion();
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn(CONTENT_STYLES, className)}
{...props}
/>
<TabsPrimitive.Content asChild {...props}>
<motion.div
data-slot="tabs-content"
initial={shouldReduceMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={
shouldReduceMotion
? { duration: 0 }
: { duration: 0.2, ease: "easeOut" }
}
className={cn(CONTENT_STYLES, className)}
>
{children}
</motion.div>
</TabsPrimitive.Content>
);
}