--- name: base-headless description: MUI Base (unstyled/headless) components and hooks — useButton, useInput, useMenu, useSlider for building custom UI triggers: - headless - unstyled - MUI Base - useButton - useInput - useMenu - useSlider - custom UI allowed-tools: - Read - Glob - Grep - Write - Edit globs: - "*.tsx" - "*.ts" --- # MUI Base (Headless/Unstyled) Components ## What is MUI Base? MUI Base (`@mui/base`) is a library of headless (unstyled) React components and hooks. Unlike Material UI, which ships with Material Design styles baked in, MUI Base provides only the logic, state management, accessibility, and keyboard interactions -- zero CSS. You bring your own styles using Tailwind, CSS Modules, styled-components, vanilla CSS, or any approach you prefer. ```bash npm install @mui/base # or pnpm add @mui/base ``` Key characteristics: - **Zero default styles** -- components render semantic HTML with no class names or CSS - **Hooks-first API** -- every component has a corresponding hook (`useButton`, `useInput`, etc.) for maximum flexibility - **WAI-ARIA compliant** -- accessibility is handled internally (focus management, keyboard navigation, ARIA attributes) - **Small bundle** -- no theme provider, no emotion/styled-engine dependency - **Composable** -- hooks return prop-getters (`getRootProps`, `getInputProps`) that you spread onto your own elements ## When to Use MUI Base | Scenario | Use MUI Base? | |----------|--------------| | Building a custom design system (not Material Design) | Yes | | Integrating with Tailwind CSS or other utility-first CSS | Yes | | Need maximum control over rendered HTML and styles | Yes | | Want Material Design out of the box | No -- use Material UI | | Need a quick prototype with default styling | No -- use Material UI or Joy UI | | Building a white-label product with multiple brand themes | Yes | ## Core Hooks ### useButton Provides button behavior including click handling, disabled state, focus-visible detection, and keyboard activation. ```tsx import { useButton } from '@mui/base/useButton'; import { useRef } from 'react'; import clsx from 'clsx'; interface CustomButtonProps { children: React.ReactNode; disabled?: boolean; onClick?: React.MouseEventHandler; variant?: 'primary' | 'secondary' | 'ghost'; } function CustomButton({ children, disabled, onClick, variant = 'primary' }: CustomButtonProps) { const buttonRef = useRef(null); const { getRootProps, active, disabled: isDisabled, focusVisible } = useButton({ disabled, rootRef: buttonRef, }); return ( ); } ``` **Returned values from `useButton`:** | Property | Type | Description | |----------|------|-------------| | `getRootProps` | `(externalProps?) => props` | Spread onto the root element (button/anchor) | | `active` | `boolean` | True while the button is being pressed | | `disabled` | `boolean` | Reflects the disabled state | | `focusVisible` | `boolean` | True when focused via keyboard (not mouse) | ### useInput Manages input state including focus, error, and adornment support. ```tsx import { useInput } from '@mui/base/useInput'; import { useRef } from 'react'; import clsx from 'clsx'; interface CustomInputProps { placeholder?: string; value?: string; onChange?: React.ChangeEventHandler; error?: boolean; disabled?: boolean; startAdornment?: React.ReactNode; endAdornment?: React.ReactNode; } function CustomInput({ placeholder, value, onChange, error, disabled, startAdornment, endAdornment, }: CustomInputProps) { const inputRef = useRef(null); const { getRootProps, getInputProps, focused, error: hasError, disabled: isDisabled, } = useInput({ value, onChange, error, disabled, inputRef, }); return (
{startAdornment} {endAdornment}
); } // Usage } endAdornment={Ctrl+K} /> ``` **Returned values from `useInput`:** | Property | Type | Description | |----------|------|-------------| | `getRootProps` | `(externalProps?) => props` | Spread onto the wrapper element | | `getInputProps` | `(externalProps?) => props` | Spread onto the `` element | | `focused` | `boolean` | True when the input has focus | | `error` | `boolean` | Reflects the error state | | `disabled` | `boolean` | Reflects the disabled state | | `value` | `string` | Current input value (controlled) | ### useMenu / useMenuItem Build accessible dropdown menus with keyboard navigation, highlight management, and open/close state. ```tsx import { useMenu } from '@mui/base/useMenu'; import { useMenuItem } from '@mui/base/useMenuItem'; import { useDropdown, DropdownContext } from '@mui/base/useDropdown'; import { useMenuButton } from '@mui/base/useMenuButton'; import { useRef, useState } from 'react'; import clsx from 'clsx'; function MenuButton({ children }: { children: React.ReactNode }) { const buttonRef = useRef(null); const { getRootProps, active } = useMenuButton({ rootRef: buttonRef }); return ( ); } function MenuItem({ children, onClick, disabled, }: { children: React.ReactNode; onClick?: () => void; disabled?: boolean; }) { const ref = useRef(null); const { getRootProps, highlighted, disabled: isDisabled } = useMenuItem({ rootRef: ref, onClick, disabled, }); return (
  • {children}
  • ); } function Menu({ children }: { children: React.ReactNode }) { const listboxRef = useRef(null); const { getListboxProps, open } = useMenu({ listboxRef }); if (!open) return null; return (
      {children}
    ); } // Full dropdown composition function CustomDropdown() { const { contextValue } = useDropdown(); return (
    Actions console.log('Edit')}>Edit console.log('Duplicate')}>Duplicate Archive console.log('Delete')}>Delete
    ); } ``` **Key props from `useMenu`:** | Property | Type | Description | |----------|------|-------------| | `getListboxProps` | `(externalProps?) => props` | Spread onto the `
      ` element | | `open` | `boolean` | Whether the menu is open | | `highlightedValue` | `string \| null` | Currently highlighted item value | | `dispatch` | `function` | Dispatch menu actions (highlight, select, close) | ### useSlider Full slider behavior with thumb positioning, marks, range support, and value management. ```tsx import { useSlider } from '@mui/base/useSlider'; import { useRef } from 'react'; import clsx from 'clsx'; interface CustomSliderProps { value?: number; defaultValue?: number; min?: number; max?: number; step?: number; onChange?: (event: Event, value: number | number[]) => void; marks?: boolean | Array<{ value: number; label?: string }>; disabled?: boolean; } function CustomSlider({ value, defaultValue = 50, min = 0, max = 100, step = 1, onChange, marks, disabled, }: CustomSliderProps) { const rootRef = useRef(null); const { getRootProps, getThumbProps, getRailProps, getTrackProps, active, values, dragging, } = useSlider({ value: value !== undefined ? [value] : undefined, defaultValue: [defaultValue], min, max, step, onChange, disabled, rootRef, }); const percentage = ((values[0] - min) / (max - min)) * 100; return (
      {/* Rail (background track) */} {/* Active track */} {/* Thumb */}
      {/* Value label */}
      {values[0]}
      ); } // Usage console.log(val)} /> ``` **Returned values from `useSlider`:** | Property | Type | Description | |----------|------|-------------| | `getRootProps` | `(externalProps?) => props` | Spread onto the container | | `getThumbProps` | `(index) => props` | Spread onto each thumb element | | `getRailProps` | `() => props` | Spread onto the rail (full track background) | | `getTrackProps` | `() => props` | Spread onto the active track fill | | `values` | `number[]` | Current value(s) -- array for range sliders | | `active` | `number` | Index of the active thumb (-1 if none) | | `dragging` | `boolean` | True while a thumb is being dragged | ### useSwitch Toggle switch behavior with checked state management and accessibility. ```tsx import { useSwitch } from '@mui/base/useSwitch'; import clsx from 'clsx'; interface CustomSwitchProps { checked?: boolean; defaultChecked?: boolean; onChange?: (event: React.ChangeEvent) => void; disabled?: boolean; label?: string; } function CustomSwitch({ checked, defaultChecked, onChange, disabled, label, }: CustomSwitchProps) { const { getInputProps, checked: isChecked, disabled: isDisabled, focusVisible, } = useSwitch({ checked, defaultChecked, onChange, disabled, }); return ( ); } ``` ### useSelect Custom select/dropdown with option management, keyboard navigation, and multi-select support. ```tsx import { useSelect } from '@mui/base/useSelect'; import { useRef, useState } from 'react'; import clsx from 'clsx'; interface Option { value: string; label: string; disabled?: boolean; } interface CustomSelectProps { options: Option[]; value?: string; onChange?: (value: string | null) => void; placeholder?: string; } function CustomSelect({ options, value, onChange, placeholder = 'Select...' }: CustomSelectProps) { const listboxRef = useRef(null); const buttonRef = useRef(null); const { getButtonProps, getListboxProps, getOptionProps, open, value: selectedValue, highlightedOption, } = useSelect({ listboxRef, buttonRef, options: options.map((opt) => ({ value: opt.value, label: opt.label, disabled: opt.disabled, })), value, onChange: (_, newValue) => onChange?.(newValue), }); const selectedLabel = options.find((o) => o.value === selectedValue)?.label; return (
      {open && (
        {options.map((option) => (
      • {option.label}
      • ))}
      )}
      ); } ``` ### useTabs / useTab Accessible tab navigation with panel association and keyboard support. ```tsx import { useTabs } from '@mui/base/useTabs'; import { useTab } from '@mui/base/useTab'; import { useTabPanel } from '@mui/base/useTabPanel'; import { useTabsList } from '@mui/base/useTabsList'; import { useRef } from 'react'; import clsx from 'clsx'; function CustomTabs({ children, defaultValue }: { children: React.ReactNode; defaultValue: string }) { const { contextValue } = useTabs({ defaultValue }); return ( {children} ); } function TabsList({ children }: { children: React.ReactNode }) { const ref = useRef(null); const { getRootProps } = useTabsList({ rootRef: ref }); return (
      {children}
      ); } function Tab({ value, children }: { value: string; children: React.ReactNode }) { const ref = useRef(null); const { getRootProps, selected, highlighted } = useTab({ value, rootRef: ref }); return ( ); } function TabPanel({ value, children }: { value: string; children: React.ReactNode }) { const ref = useRef(null); const { getRootProps, hidden } = useTabPanel({ value, rootRef: ref }); if (hidden) return null; return (
      {children}
      ); } // Usage Overview Features Pricing Overview content... Features content... Pricing content... ``` ## Tailwind CSS Integration MUI Base hooks are the ideal companion for Tailwind CSS because they handle behavior while Tailwind handles presentation. ### Pattern: Hook + Tailwind utility classes ```tsx import { useButton } from '@mui/base/useButton'; import { cva, type VariantProps } from 'class-variance-authority'; // Define variants with cva (class-variance-authority) const buttonVariants = cva( 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2', { variants: { variant: { default: 'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500', destructive: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500', outline: 'border border-gray-300 bg-white hover:bg-gray-50 focus-visible:ring-gray-500', ghost: 'hover:bg-gray-100 focus-visible:ring-gray-500', }, size: { sm: 'h-8 px-3 text-xs', md: 'h-10 px-4 text-sm', lg: 'h-12 px-6 text-base', }, }, defaultVariants: { variant: 'default', size: 'md', }, } ); interface ButtonProps extends VariantProps { children: React.ReactNode; disabled?: boolean; onClick?: React.MouseEventHandler; } function Button({ children, variant, size, disabled, onClick }: ButtonProps) { const ref = useRef(null); const { getRootProps, active, focusVisible } = useButton({ disabled, rootRef: ref }); return ( ); } ``` ### Pattern: Composing multiple hooks into a form ```tsx function SearchForm() { const [query, setQuery] = useState(''); const inputRef = useRef(null); const { getInputProps, getRootProps: getInputRootProps, focused } = useInput({ value: query, onChange: (e) => setQuery((e.target as HTMLInputElement).value), inputRef, }); const buttonRef = useRef(null); const { getRootProps: getButtonRootProps } = useButton({ rootRef: buttonRef }); return (
      ); } ``` ## Comparison: MUI Base vs Material UI vs Radix / Headless UI | Feature | MUI Base | Material UI | Radix UI | Headless UI | |---------|----------|-------------|----------|-------------| | **Styles included** | None | Material Design | None | None | | **API style** | Hooks + components | Components | Components (primitives) | Components | | **Bundle size** | Small | Large | Small | Small | | **Accessibility** | Built-in | Built-in | Built-in | Built-in | | **TypeScript** | Full | Full | Full | Full | | **Theme system** | None (bring your own) | Full theme provider | CSS variables | None | | **Component count** | ~15 hooks | 40+ components | 25+ primitives | ~10 components | | **Learning curve** | Moderate (hooks pattern) | Low (ready-made) | Low-moderate | Low | | **Best for** | Custom design systems | Quick Material Design apps | Custom + Tailwind | Tailwind projects | | **React Server Components** | Compatible | Needs 'use client' | Compatible | Compatible | | **Maintained by** | MUI team | MUI team | WorkOS | Tailwind Labs | ### When to choose MUI Base over alternatives - You are already using other MUI packages and want consistency in the hooks API - You need more granular control than Radix provides (prop-getters vs render props) - You want a single vendor for both unstyled and styled components (can mix `@mui/base` and `@mui/material`) - You prefer the hooks-first pattern over compound component patterns used by Radix ### When to choose Radix or Headless UI instead - You want a larger set of ready-made primitives (Radix has Dialog, Popover, Toast, Tooltip, etc.) - You prefer the composition/slot pattern over hooks - Your project is purely Tailwind-based and you want the tightest Tailwind integration (Headless UI) --- ## Advanced: OwnerState-Driven Slots Slots receive `ownerState` — the component's internal state plus custom flags you inject. ```tsx import Switch from '@mui/base/Switch'; import { styled } from '@mui/system'; // Extended owner state with custom "critical" flag interface AdvancedOwnerState { checked: boolean; disabled: boolean; focusVisible: boolean; critical?: boolean; } const Track = styled('span', { shouldForwardProp: (prop) => prop !== 'ownerState', })<{ ownerState: AdvancedOwnerState }>(({ ownerState }) => ({ width: 46, height: 24, borderRadius: 999, backgroundColor: ownerState.checked ? ownerState.critical ? 'rgba(239,68,68,0.25)' : 'rgba(56,189,248,0.25)' : 'rgba(15,23,42,0.85)', transition: 'background-color 150ms ease', })); const Thumb = styled('span', { shouldForwardProp: (prop) => prop !== 'ownerState', })<{ ownerState: AdvancedOwnerState }>(({ ownerState }) => ({ position: 'absolute', top: 2, left: ownerState.checked ? 24 : 2, width: 20, height: 20, borderRadius: '50%', background: 'linear-gradient(135deg, #f9fafb, #e5e7eb)', transition: 'left 150ms cubic-bezier(0.4, 0, 0.2, 1)', })); // Inject custom ownerState via slotProps callback ({ ownerState: { ...baseOwnerState, critical: true } as AdvancedOwnerState, }), thumb: (baseOwnerState) => ({ ownerState: { ...baseOwnerState, critical: true } as AdvancedOwnerState, }), input: { className: 'sr-only' }, }} /> ``` **Pattern applies to all Base UI components** — Tabs, Menus, Comboboxes, Sliders. Custom `ownerState` flags let you drive complex visual states from a single prop. --- ## Advanced: Slot Wrappers for Third-Party Libraries Wrap external components in slot-compatible components that filter ownerState: ```tsx // Prevent ownerState from leaking onto DOM of a third-party component const ChartSlot = forwardRef( ({ ownerState, data, ...props }, ref) => { // Extract only what we need from ownerState const isExpanded = ownerState?.expanded ?? false; return (
      ); }, ); ```