The Evolution of Compound Components: From Namespaces to Flat Exports

If you’ve been building with React for a while, you’re likely familiar with the Compound Component pattern. It’s one of the most elegant ways to build expressive, flexible, and declarative UI components. For years, the gold standard for implementing this pattern looked like this:
// The Classic Namespaced Look (Radix UI / Ark UI) import * as Carousel from "@architecture/carousel" <Carousel.Root> <Carousel.Content> <Carousel.Item /> </Carousel.Content> <Carousel.Previous /> <Carousel.Next /> </Carousel.Root>
It’s highly readable, tightly encapsulated, and explicitly maps out component relationships. Primitives like Radix UI and Ark UI still successfully leverage this namespaced approach today.
However, if you open up shadcn/ui, you’ll notice a distinct architectural shift. It bypasses the dot-notation syntax entirely in favor of a "flat" named export approach:
// The Flat-Export Look (shadcn/ui) import { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext } from "@/components/ui/carousel" <Carousel> <CarouselContent> <CarouselItem /> </Carousel> <CarouselPrevious /> <CarouselNext /> </Carousel>
Why did shadcn/ui diverge from the underlying primitives it's built on? It wasn't just a visual preference.
Curious about this shift, I decided to investigate the technical reasons behind it. Below, I’ve put together a comparison of the namespaced version (used by Radix UI and Ark UI) and the flat export version (used by shadcn/ui), along with a few other interesting findings from my research.
Before looking at why shadcn/ui flattened the pattern, it's worth noting what didn't change. Both approaches share identical runtime DNA: React Context.
Regardless of whether you write <Carousel.Content> or <CarouselContent>, the underlying state management relies on an implicit contract. A parent context provider holds the state, and a custom consumer hook manages individual component interactions.
The difference between these two paradigms isn’t how they manage state—it’s how they declare JavaScript module boundaries and interact with static compilation tools when the code lives directly inside your repository.
However, before diving into module boundaries and build tools, it helps to look at how these patterns handle passing refs—a challenge you immediately face when defining sub-components.
forwardRef to React 19In production UI libraries, sub-components must expose their underlying DOM nodes via refs so focus managers, animation libraries, and measurement utilities can interact with them. Tracing how namespaced patterns interacted with React's ref APIs over time reveals why flat exports became so compelling to write in plain code.
forwardRef Era (The Type Tax)Historically, forwarding a ref meant wrapping your component function in React.forwardRef. Combining that wrapper with namespaced sub-components created an awkward TypeScript scenario.
Because React.forwardRef returned a generic ForwardRefExoticComponent type, you couldn't simply append properties to the returned function instance. You had to define a custom intersection type and manually cast the result:
const CarouselRoot = React.forwardRef<HTMLDivElement, CarouselProps>((props, ref) => { return <div ref={ref} {...props} /> }) // A fragile type intersection to satisfy the compiler interface CarouselComponent extends React.ForwardRefExoticComponent<CarouselProps & React.RefAttributes<HTMLDivElement>> { Content: typeof CarouselContent Item: typeof CarouselItem } // Manual runtime mutation paired with type casting const Carousel = CarouselRoot as CarouselComponent Carousel.Content = CarouselContent Carousel.Item = CarouselItem
For a packaged library like Radix or Ark, this type tax is paid once by the maintainers and shipped clean to the consumer. But because shadcn/ui gives you the raw source code, forcing this boilerplate into your local codebase would add maintenance overhead.
React 19 completely eliminated this friction by deprecating React.forwardRef. Components are now plain, native JavaScript functions. The ref is simply passed as a standard prop inside the primary argument object.
This allowed shadcn/ui to adopt clean, native TypeScript function signatures without any namespacing workarounds:
import type { ComponentProps } from "react" // In React 19, ref is natively integrated into ComponentProps export function Carousel({ ref, ...props }: ComponentProps<"div">) { return <div ref={ref} {...props} /> } export function CarouselContent({ ref, ...props }: ComponentProps<"div">) { return <div ref={ref} {...props} /> }
By decoupling components from parent object namespaces, this flat structure also ensures full compatibility with the React Compiler, which relies on analyzing clean, side-effect-free function blocks to automatically inject granular memoization.
The distinction between flat exports and namespaces dictates whether your application module boundaries are determined statically at compile-time or dynamically at runtime. This matters significantly less for an external node_modules package, but it is critical for code living inside your /components directory.
When you bundle local files using dot-notation, you often append properties onto an object or function instance (Carousel.Content = CarouselContent).
Vite 8 utilizes Rolldown to construct production dependency graphs based on a map of your application's ECMAScript Modules (ESM).
When child components are appended as properties onto an object via runtime mutation, a strict Rust-based compiler cannot guarantee optimization safety. Because an object’s keys can technically be accessed or mutated dynamically elsewhere, Rolldown can be forced to bail out of dead-code elimination (tree-shaking) for that local object tree.
Because shadcn/ui components are completely flat, they leverage pure ECMAScript Modules (ESM).
import { Carousel, CarouselContent } from "@/components/ui/carousel"
Because no runtime object bridges these functions together in your local file, Rolldown has explicit visibility. If you delete or omit CarouselPrevious from your UI layout, its specific branch of the dependency graph remains unreferenced and is safely left out of your production assets.
Furthermore, this clean layout yields excellent local DX; modern IDEs natively analyze these top-level flat exports to provide instant auto-import suggestions the split second you type them.
When writing applications utilizing React Server Components, the structural layout of your local module graphs transforms from a simple bundle-size consideration into a strict network boundary requirement.
The real architectural advantage of shadcn/ui's flat-export approach under an RSC model comes down to Module Extraction and File Boundaries:
In an RSC architecture, the "use client" directive establishes a file-level boundary. If a Server Component imports any export from a file marked "use client", the browser must download that entire file module.
Because components in a flat-export architecture are entirely independent functions, separating them to optimize your network payload is an effortless task. If your core <Carousel> and <CarouselContent> are lightweight layout components that can render on the server, but <CarouselPrevious/> drags in interactive client event handlers and heavy icon libraries, you can easily extract the control buttons into their own isolated "use client" file.
// carousel.tsx — Lightweight layout; can remain a Server Component export function Carousel(props) { ... } export function CarouselContent(props) { ... } // carousel-controls.tsx — Isolated client network boundary "use client" import { LucideIcon } from "lucide-react" export function CarouselPrevious(props) { ... }
If your local components are tightly locked within a namespace object wrapper, splitting them across physical files while preserving a dot-notation API requires complex, circular re-exports across files. Flat exports give you the freedom to refactor, modify, and split your local code exactly how modern frameworks demand.
| Feature | Namespaced Primitives (Radix / Ark) | Flat Local Named Exports (shadcn/ui) |
|---|---|---|
| Distribution Model | Pre-compiled npm package | Raw, editable project source code |
| State Sharing | Shared Context | Shared Context |
| Tree-Shaking Predictability | Handled via library optimization layers | Guaranteed locally via native ESM static analysis |
| React 19 & Compiler Ergonomics | Internalized by library maintainers | Clean, zero-overhead native function syntax |
| Local File Splitting (RSC) | Rigid (Tied to a single package entry point) | Highly Flexible (Easy to separate into client/server files) |
| IDE Auto-Imports | Requires importing the root namespace | Seamless, top-level auto-imports |
The namespaced compound component pattern used by Radix UI and Ark UI remains an exceptional API design for packaged dependencies—it keeps the global scope clean and organizes primitives predictably.
However, shadcn/ui’s shift to flat named exports was a deliberate acknowledgment of its unique distribution model. When you own the source code, flat exports align beautifully with old/modern tools: zero-overhead type signatures via React 19, deterministic tree-shaking via Rust build tools, and friction-free module splitting across React Server Component boundaries.
I hope this breakdown is helpful! I may have missed a few angles, so if you have any questions, suggestions, or know of a better approach, I would love to hear your thoughts. I'm always open to learning and improving. Stay humble and keep learning!