Skip to main content
Generalmarkus41

base-headless

MUI Base (unstyled/headless) components and hooks — useButton, useInput, useMenu, useSlider for building custom UI

Stars
12
Source
markus41/claude
Updated
2026-05-11
Slug
markus41--claude--base-headless
View on GitHubRaw SKILL.md

// install — copy + paste into any project

mkdir -p .claude/skills && curl -fsSL https://raw.githubusercontent.com/markus41/claude/HEAD/plugins/mui-expert/skills/base-headless/SKILL.md -o .claude/skills/base-headless.md

Drops the SKILL.md into .claude/skills/base-headless.md. Works with Claude Code, Cursor, and any agent that loads SKILL.md files from .claude/skills/.

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.

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.

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<HTMLButtonElement>(null);
  const { getRootProps, active, disabled: isDisabled, focusVisible } = useButton({
    disabled,
    rootRef: buttonRef,
  });

  return (
    <button
      {...getRootProps({ onClick })}
      className={clsx(
        'px-4 py-2 rounded-lg font-medium transition-all',
        variant === 'primary' && 'bg-blue-600 text-white hover:bg-blue-700',
        variant === 'secondary' && 'bg-gray-200 text-gray-800 hover:bg-gray-300',
        variant === 'ghost' && 'bg-transparent text-gray-600 hover:bg-gray-100',
        active && 'scale-95',
        isDisabled && 'opacity-50 cursor-not-allowed',
        focusVisible && 'ring-2 ring-blue-400 ring-offset-2',
      )}
    >
      {children}
    </button>
  );
}

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.

import { useInput } from '@mui/base/useInput';
import { useRef } from 'react';
import clsx from 'clsx';

interface CustomInputProps {
  placeholder?: string;
  value?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
  error?: boolean;
  disabled?: boolean;
  startAdornment?: React.ReactNode;
  endAdornment?: React.ReactNode;
}

function CustomInput({
  placeholder,
  value,
  onChange,
  error,
  disabled,
  startAdornment,
  endAdornment,
}: CustomInputProps) {
  const inputRef = useRef<HTMLInputElement>(null);
  const {
    getRootProps,
    getInputProps,
    focused,
    error: hasError,
    disabled: isDisabled,
  } = useInput({
    value,
    onChange,
    error,
    disabled,
    inputRef,
  });

  return (
    <div
      {...getRootProps()}
      className={clsx(
        'flex items-center gap-2 px-3 py-2 border rounded-lg transition-colors',
        focused && 'border-blue-500 ring-1 ring-blue-500',
        hasError && 'border-red-500 ring-1 ring-red-500',
        isDisabled && 'bg-gray-100 opacity-60',
        !focused && !hasError && 'border-gray-300 hover:border-gray-400',
      )}
    >
      {startAdornment}
      <input
        {...getInputProps()}
        placeholder={placeholder}
        className="flex-1 outline-none bg-transparent text-sm"
      />
      {endAdornment}
    </div>
  );
}

// Usage
<CustomInput
  placeholder="Search..."
  startAdornment={<SearchIcon className="w-4 h-4 text-gray-400" />}
  endAdornment={<kbd className="text-xs text-gray-400">Ctrl+K</kbd>}
/>

Returned values from useInput:

Property Type Description
getRootProps (externalProps?) => props Spread onto the wrapper element
getInputProps (externalProps?) => props Spread onto the <input> 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.

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<HTMLButtonElement>(null);
  const { getRootProps, active } = useMenuButton({ rootRef: buttonRef });

  return (
    <button
      {...getRootProps()}
      className={clsx(
        'px-4 py-2 bg-white border rounded-lg shadow-sm hover:bg-gray-50',
        active && 'bg-gray-100',
      )}
    >
      {children}
    </button>
  );
}

function MenuItem({
  children,
  onClick,
  disabled,
}: {
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
}) {
  const ref = useRef<HTMLLIElement>(null);
  const { getRootProps, highlighted, disabled: isDisabled } = useMenuItem({
    rootRef: ref,
    onClick,
    disabled,
  });

  return (
    <li
      {...getRootProps()}
      className={clsx(
        'px-4 py-2 text-sm cursor-pointer transition-colors',
        highlighted && 'bg-blue-50 text-blue-700',
        isDisabled && 'text-gray-400 cursor-not-allowed',
        !highlighted && !isDisabled && 'text-gray-700 hover:bg-gray-50',
      )}
    >
      {children}
    </li>
  );
}

function Menu({ children }: { children: React.ReactNode }) {
  const listboxRef = useRef<HTMLUListElement>(null);
  const { getListboxProps, open } = useMenu({ listboxRef });

  if (!open) return null;

  return (
    <ul
      {...getListboxProps()}
      className="absolute mt-1 w-56 bg-white border rounded-lg shadow-lg py-1 z-50"
    >
      {children}
    </ul>
  );
}

// Full dropdown composition
function CustomDropdown() {
  const { contextValue } = useDropdown();

  return (
    <DropdownContext.Provider value={contextValue}>
      <div className="relative inline-block">
        <MenuButton>Actions</MenuButton>
        <Menu>
          <MenuItem onClick={() => console.log('Edit')}>Edit</MenuItem>
          <MenuItem onClick={() => console.log('Duplicate')}>Duplicate</MenuItem>
          <MenuItem disabled>Archive</MenuItem>
          <MenuItem onClick={() => console.log('Delete')}>Delete</MenuItem>
        </Menu>
      </div>
    </DropdownContext.Provider>
  );
}

Key props from useMenu:

Property Type Description
getListboxProps (externalProps?) => props Spread onto the <ul> 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.

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<HTMLDivElement>(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 (
    <div className="w-full py-4">
      <div
        {...getRootProps()}
        className={clsx(
          'relative h-2 cursor-pointer',
          disabled && 'opacity-50 cursor-not-allowed',
        )}
      >
        {/* Rail (background track) */}
        <span
          {...getRailProps()}
          className="absolute w-full h-full rounded-full bg-gray-200"
        />

        {/* Active track */}
        <span
          {...getTrackProps()}
          className="absolute h-full rounded-full bg-blue-500"
          style={{ width: `${percentage}%` }}
        />

        {/* Thumb */}
        <span
          {...getThumbProps(0)}
          className={clsx(
            'absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-5 h-5',
            'rounded-full bg-white border-2 border-blue-500 shadow-md',
            'transition-shadow hover:shadow-lg',
            active === 0 && 'shadow-lg ring-4 ring-blue-100',
            dragging && 'scale-110',
          )}
          style={{ left: `${percentage}%` }}
        />
      </div>

      {/* Value label */}
      <div className="mt-2 text-sm text-gray-600 text-center">
        {values[0]}
      </div>
    </div>
  );
}

// Usage
<CustomSlider
  defaultValue={30}
  min={0}
  max={100}
  step={5}
  onChange={(_, val) => 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.

import { useSwitch } from '@mui/base/useSwitch';
import clsx from 'clsx';

interface CustomSwitchProps {
  checked?: boolean;
  defaultChecked?: boolean;
  onChange?: (event: React.ChangeEvent<HTMLInputElement>) => 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 (
    <label className={clsx(
      'inline-flex items-center gap-3 cursor-pointer',
      isDisabled && 'opacity-50 cursor-not-allowed',
    )}>
      <span className="relative">
        <input {...getInputProps()} className="sr-only" />
        <span
          className={clsx(
            'block w-10 h-6 rounded-full transition-colors',
            isChecked ? 'bg-blue-600' : 'bg-gray-300',
            focusVisible && 'ring-2 ring-blue-400 ring-offset-2',
          )}
        />
        <span
          className={clsx(
            'absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform',
            isChecked && 'translate-x-4',
          )}
        />
      </span>
      {label && <span className="text-sm text-gray-700">{label}</span>}
    </label>
  );
}

useSelect

Custom select/dropdown with option management, keyboard navigation, and multi-select support.

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<HTMLUListElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);

  const {
    getButtonProps,
    getListboxProps,
    getOptionProps,
    open,
    value: selectedValue,
    highlightedOption,
  } = useSelect<string, false>({
    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 (
    <div className="relative inline-block w-64">
      <button
        {...getButtonProps()}
        className={clsx(
          'w-full px-4 py-2 text-left bg-white border rounded-lg shadow-sm',
          'flex items-center justify-between',
          open && 'border-blue-500 ring-1 ring-blue-500',
          !open && 'border-gray-300 hover:border-gray-400',
        )}
      >
        <span className={selectedLabel ? 'text-gray-900' : 'text-gray-400'}>
          {selectedLabel || placeholder}
        </span>
        <ChevronDownIcon className={clsx('w-4 h-4 transition-transform', open && 'rotate-180')} />
      </button>

      {open && (
        <ul
          {...getListboxProps()}
          className="absolute mt-1 w-full bg-white border rounded-lg shadow-lg py-1 z-50 max-h-60 overflow-auto"
        >
          {options.map((option) => (
            <li
              key={option.value}
              {...getOptionProps(option.value)}
              className={clsx(
                'px-4 py-2 text-sm cursor-pointer',
                highlightedOption === option.value && 'bg-blue-50 text-blue-700',
                option.value === selectedValue && 'font-medium',
                option.disabled && 'text-gray-400 cursor-not-allowed',
              )}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

useTabs / useTab

Accessible tab navigation with panel association and keyboard support.

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 (
    <TabsContext.Provider value={contextValue}>
      {children}
    </TabsContext.Provider>
  );
}

function TabsList({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const { getRootProps } = useTabsList({ rootRef: ref });

  return (
    <div
      {...getRootProps()}
      className="flex border-b border-gray-200 gap-1"
    >
      {children}
    </div>
  );
}

function Tab({ value, children }: { value: string; children: React.ReactNode }) {
  const ref = useRef<HTMLButtonElement>(null);
  const { getRootProps, selected, highlighted } = useTab({ value, rootRef: ref });

  return (
    <button
      {...getRootProps()}
      className={clsx(
        'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
        selected && 'border-blue-500 text-blue-600',
        !selected && 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300',
        highlighted && 'bg-gray-50',
      )}
    >
      {children}
    </button>
  );
}

function TabPanel({ value, children }: { value: string; children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const { getRootProps, hidden } = useTabPanel({ value, rootRef: ref });

  if (hidden) return null;

  return (
    <div {...getRootProps()} className="py-4">
      {children}
    </div>
  );
}

// Usage
<CustomTabs defaultValue="tab1">
  <TabsList>
    <Tab value="tab1">Overview</Tab>
    <Tab value="tab2">Features</Tab>
    <Tab value="tab3">Pricing</Tab>
  </TabsList>
  <TabPanel value="tab1">Overview content...</TabPanel>
  <TabPanel value="tab2">Features content...</TabPanel>
  <TabPanel value="tab3">Pricing content...</TabPanel>
</CustomTabs>

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

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<typeof buttonVariants> {
  children: React.ReactNode;
  disabled?: boolean;
  onClick?: React.MouseEventHandler;
}

function Button({ children, variant, size, disabled, onClick }: ButtonProps) {
  const ref = useRef<HTMLButtonElement>(null);
  const { getRootProps, active, focusVisible } = useButton({ disabled, rootRef: ref });

  return (
    <button
      {...getRootProps({ onClick })}
      className={clsx(
        buttonVariants({ variant, size }),
        active && 'scale-[0.98]',
        disabled && 'opacity-50 pointer-events-none',
      )}
    >
      {children}
    </button>
  );
}

Pattern: Composing multiple hooks into a form

function SearchForm() {
  const [query, setQuery] = useState('');
  const inputRef = useRef<HTMLInputElement>(null);

  const { getInputProps, getRootProps: getInputRootProps, focused } = useInput({
    value: query,
    onChange: (e) => setQuery((e.target as HTMLInputElement).value),
    inputRef,
  });

  const buttonRef = useRef<HTMLButtonElement>(null);
  const { getRootProps: getButtonRootProps } = useButton({ rootRef: buttonRef });

  return (
    <form className="flex gap-2">
      <div
        {...getInputRootProps()}
        className={clsx(
          'flex-1 border rounded-lg px-3 py-2',
          focused ? 'border-blue-500 ring-1 ring-blue-500' : 'border-gray-300',
        )}
      >
        <input
          {...getInputProps()}
          placeholder="Search..."
          className="w-full outline-none bg-transparent"
        />
      </div>
      <button
        {...getButtonRootProps()}
        className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
      >
        Search
      </button>
    </form>
  );
}

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.

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
<Switch
  slots={{ track: Track, thumb: Thumb, input: 'input' }}
  slotProps={{
    track: (baseOwnerState) => ({
      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:

// Prevent ownerState from leaking onto DOM of a third-party component
const ChartSlot = forwardRef<HTMLDivElement, { ownerState?: any; data: number[] }>(
  ({ ownerState, data, ...props }, ref) => {
    // Extract only what we need from ownerState
    const isExpanded = ownerState?.expanded ?? false;
    return (
      <div ref={ref} {...props}>
        <ThirdPartyChart data={data} height={isExpanded ? 400 : 200} />
      </div>
    );
  },
);