Agent Skills: Advanced MUI Component Patterns

Advanced MUI component patterns — Autocomplete (async, virtualized, grouped, createFilterOptions), Stepper (non-linear, vertical, mobile), Popover, Popper, Portal, ClickAwayListener, and complex composition

UncategorizedID: lobbi-docs/claude/advanced-components

Install this agent skill to your local

pnpm dlx add-skill https://github.com/markus41/claude/tree/HEAD/plugins/mui-expert/skills/advanced-components

Skill Files

Browse the full folder contents for advanced-components.

Download Skill

Loading file tree…

plugins/mui-expert/skills/advanced-components/SKILL.md

Skill Metadata

Name
advanced-components
Description
Advanced MUI component patterns — Autocomplete (async, virtualized, grouped, createFilterOptions), Stepper (non-linear, vertical, mobile), Popover, Popper, Portal, ClickAwayListener, and complex composition

Advanced MUI Component Patterns

Deep patterns for complex MUI components that go beyond basic usage.


Autocomplete — Advanced Patterns

Async / Server-Side Options

import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import CircularProgress from '@mui/material/CircularProgress';
import { useState, useEffect, useRef } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
}

function AsyncAutocomplete() {
  const [open, setOpen] = useState(false);
  const [options, setOptions] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [inputValue, setInputValue] = useState('');
  const debounceRef = useRef<NodeJS.Timeout>();

  useEffect(() => {
    if (!open) return;
    if (!inputValue) {
      setOptions([]);
      return;
    }

    // Debounce API calls
    clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(async () => {
      setLoading(true);
      try {
        const res = await fetch(`/api/users?search=${encodeURIComponent(inputValue)}`);
        const data: User[] = await res.json();
        setOptions(data);
      } finally {
        setLoading(false);
      }
    }, 300);

    return () => clearTimeout(debounceRef.current);
  }, [inputValue, open]);

  return (
    <Autocomplete
      open={open}
      onOpen={() => setOpen(true)}
      onClose={() => setOpen(false)}
      options={options}
      loading={loading}
      getOptionLabel={(option) => option.name}
      isOptionEqualToValue={(option, value) => option.id === value.id}
      onInputChange={(_, value) => setInputValue(value)}
      filterOptions={(x) => x} // disable client-side filtering — server handles it
      renderInput={(params) => (
        <TextField
          {...params}
          label="Search users"
          slotProps={{
            input: {
              ...params.InputProps,
              endAdornment: (
                <>
                  {loading && <CircularProgress color="inherit" size={20} />}
                  {params.InputProps.endAdornment}
                </>
              ),
            },
          }}
        />
      )}
      renderOption={(props, option) => (
        <li {...props} key={option.id}>
          <Box>
            <Typography variant="body1">{option.name}</Typography>
            <Typography variant="caption" color="text.secondary">{option.email}</Typography>
          </Box>
        </li>
      )}
    />
  );
}

createFilterOptions — Custom Filtering

import { createFilterOptions } from '@mui/material/Autocomplete';

interface Film {
  title: string;
  year: number;
  inputValue?: string; // for "create" option
}

// Custom filter: match start of title, limit to 10
const filter = createFilterOptions<Film>({
  matchFrom: 'start',    // 'start' | 'any' (default: 'any')
  limit: 10,             // max options shown
  stringify: (option) => option.title, // what to search against
  ignoreAccents: true,   // normalize accents
  ignoreCase: true,      // case-insensitive (default: true)
  trim: true,            // trim whitespace
});

// "Create" option pattern — add user-typed value as new option
<Autocomplete
  freeSolo
  selectOnFocus
  clearOnBlur
  handleHomeEndKeys
  options={films}
  filterOptions={(options, params) => {
    const filtered = filter(options, params);

    const { inputValue } = params;
    // Suggest creating a new value
    const isExisting = options.some((option) => inputValue === option.title);
    if (inputValue !== '' && !isExisting) {
      filtered.push({
        inputValue,
        title: `Add "${inputValue}"`,
        year: new Date().getFullYear(),
      });
    }
    return filtered;
  }}
  getOptionLabel={(option) => {
    if (typeof option === 'string') return option;
    if (option.inputValue) return option.inputValue;
    return option.title;
  }}
  onChange={(_, newValue) => {
    if (newValue && typeof newValue !== 'string' && newValue.inputValue) {
      // User chose "Add ..." — create the new item
      handleCreate(newValue.inputValue);
    }
  }}
  renderInput={(params) => <TextField {...params} label="Film" />}
/>

Virtualized Autocomplete (10,000+ options)

import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { forwardRef, HTMLAttributes } from 'react';

const ITEM_HEIGHT = 36;
const LISTBOX_PADDING = 8;

function renderRow(props: ListChildComponentProps) {
  const { data, index, style } = props;
  const dataSet = data[index];
  const inlineStyle = {
    ...style,
    top: (style.top as number) + LISTBOX_PADDING,
  };
  return (
    <li {...dataSet[0]} style={inlineStyle} key={dataSet[1].id}>
      {dataSet[1].label}
    </li>
  );
}

const ListboxComponent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLElement>>(
  function ListboxComponent(props, ref) {
    const { children, ...other } = props;
    const items = children as [HTMLAttributes<HTMLElement>, { label: string; id: string }][];
    const itemCount = items.length;
    const height = Math.min(8, itemCount) * ITEM_HEIGHT + 2 * LISTBOX_PADDING;

    return (
      <div ref={ref} {...other}>
        <FixedSizeList
          height={height}
          width="100%"
          itemSize={ITEM_HEIGHT}
          itemCount={itemCount}
          itemData={items}
          overscanCount={5}
        >
          {renderRow}
        </FixedSizeList>
      </div>
    );
  }
);

<Autocomplete
  disableListWrap
  slots={{ listbox: ListboxComponent }}
  options={tenThousandOptions}
  renderInput={(params) => <TextField {...params} label="10,000 options" />}
/>

Grouped Options

<Autocomplete
  options={countries.sort((a, b) => -b.continent.localeCompare(a.continent))}
  groupBy={(option) => option.continent}
  getOptionLabel={(option) => option.name}
  renderGroup={(params) => (
    <li key={params.key}>
      <GroupHeader>{params.group}</GroupHeader>
      <GroupItems>{params.children}</GroupItems>
    </li>
  )}
  renderInput={(params) => <TextField {...params} label="Country" />}
/>

Multiple with Chip Limit

<Autocomplete
  multiple
  limitTags={2}           // show max 2 chips, "+N" for overflow
  disableCloseOnSelect    // keep dropdown open after selection
  options={allTags}
  getOptionLabel={(option) => option.label}
  renderTags={(value, getTagProps, ownerState) =>
    value.map((option, index) => {
      const { key, ...tagProps } = getTagProps({ index });
      return (
        <Chip
          key={key}
          label={option.label}
          size="small"
          color="primary"
          variant="outlined"
          {...tagProps}
        />
      );
    })
  }
  renderInput={(params) => <TextField {...params} label="Tags" placeholder="Add..." />}
/>

Highlight Matching Text

import parse from 'autosuggest-highlight/parse';
import match from 'autosuggest-highlight/match';

<Autocomplete
  options={options}
  renderOption={(props, option, { inputValue }) => {
    const matches = match(option.label, inputValue, { insideWords: true });
    const parts = parse(option.label, matches);
    return (
      <li {...props}>
        {parts.map((part, index) => (
          <span key={index} style={{ fontWeight: part.highlight ? 700 : 400 }}>
            {part.text}
          </span>
        ))}
      </li>
    );
  }}
  renderInput={(params) => <TextField {...params} label="Highlight demo" />}
/>

Stepper — Advanced Patterns

Horizontal Stepper with Validation

import Stepper from '@mui/material/Stepper';
import Step from '@mui/material/Step';
import StepLabel from '@mui/material/StepLabel';
import StepContent from '@mui/material/StepContent';
import Button from '@mui/material/Button';

const steps = ['Account', 'Profile', 'Confirm'];

function HorizontalStepper() {
  const [activeStep, setActiveStep] = useState(0);
  const [errors, setErrors] = useState<Record<number, string>>({});

  const validateStep = (step: number): boolean => {
    // Per-step validation logic
    switch (step) {
      case 0: return !!formData.email && !!formData.password;
      case 1: return !!formData.name;
      default: return true;
    }
  };

  const handleNext = () => {
    if (validateStep(activeStep)) {
      setErrors((prev) => ({ ...prev, [activeStep]: '' }));
      setActiveStep((prev) => prev + 1);
    } else {
      setErrors((prev) => ({ ...prev, [activeStep]: 'Please fill all fields' }));
    }
  };

  return (
    <>
      <Stepper activeStep={activeStep} alternativeLabel>
        {steps.map((label, index) => (
          <Step key={label}>
            <StepLabel
              error={!!errors[index]}
              optional={errors[index] ? (
                <Typography variant="caption" color="error">{errors[index]}</Typography>
              ) : undefined}
            >
              {label}
            </StepLabel>
          </Step>
        ))}
      </Stepper>
      {/* Step content */}
      {activeStep === 0 && <AccountForm />}
      {activeStep === 1 && <ProfileForm />}
      {activeStep === 2 && <ConfirmStep />}
      <Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
        <Button disabled={activeStep === 0} onClick={() => setActiveStep((p) => p - 1)}>
          Back
        </Button>
        <Button variant="contained" onClick={handleNext}>
          {activeStep === steps.length - 1 ? 'Finish' : 'Next'}
        </Button>
      </Box>
    </>
  );
}

Vertical Stepper (Step Content)

<Stepper activeStep={activeStep} orientation="vertical">
  {steps.map((step, index) => (
    <Step key={step.label}>
      <StepLabel
        optional={index === steps.length - 1 ? (
          <Typography variant="caption">Last step</Typography>
        ) : undefined}
      >
        {step.label}
      </StepLabel>
      <StepContent TransitionProps={{ unmountOnExit: false }}>
        <Typography>{step.description}</Typography>
        {step.content}
        <Box sx={{ mt: 2 }}>
          <Button variant="contained" onClick={handleNext} sx={{ mr: 1 }}>
            {index === steps.length - 1 ? 'Finish' : 'Continue'}
          </Button>
          <Button disabled={index === 0} onClick={handleBack}>Back</Button>
        </Box>
      </StepContent>
    </Step>
  ))}
</Stepper>

Non-Linear Stepper

function NonLinearStepper() {
  const [activeStep, setActiveStep] = useState(0);
  const [completed, setCompleted] = useState<Record<number, boolean>>({});

  const handleStep = (step: number) => () => {
    setActiveStep(step); // Jump to any step
  };

  const handleComplete = () => {
    setCompleted((prev) => ({ ...prev, [activeStep]: true }));
    // Move to next incomplete step
    const next = steps.findIndex((_, i) => !completed[i] && i !== activeStep);
    if (next !== -1) setActiveStep(next);
  };

  const allCompleted = Object.keys(completed).length === steps.length;

  return (
    <Stepper nonLinear activeStep={activeStep}>
      {steps.map((label, index) => (
        <Step key={label} completed={completed[index]}>
          <StepButton color="inherit" onClick={handleStep(index)}>
            {label}
          </StepButton>
        </Step>
      ))}
    </Stepper>
  );
}

Custom Step Icons & Connectors

import StepConnector, { stepConnectorClasses } from '@mui/material/StepConnector';
import { StepIconProps } from '@mui/material/StepIcon';
import { styled } from '@mui/material/styles';
import Check from '@mui/icons-material/Check';

const QontoConnector = styled(StepConnector)(({ theme }) => ({
  [`&.${stepConnectorClasses.alternativeLabel}`]: {
    top: 10,
    left: 'calc(-50% + 16px)',
    right: 'calc(50% + 16px)',
  },
  [`&.${stepConnectorClasses.active}`]: {
    [`& .${stepConnectorClasses.line}`]: {
      borderColor: theme.palette.primary.main,
    },
  },
  [`&.${stepConnectorClasses.completed}`]: {
    [`& .${stepConnectorClasses.line}`]: {
      borderColor: theme.palette.primary.main,
    },
  },
  [`& .${stepConnectorClasses.line}`]: {
    borderColor: theme.palette.divider,
    borderTopWidth: 3,
    borderRadius: 1,
  },
}));

function QontoStepIcon(props: StepIconProps) {
  const { active, completed, className } = props;
  return (
    <Box
      className={className}
      sx={{
        color: active || completed ? 'primary.main' : 'text.disabled',
        display: 'flex',
        height: 22,
        alignItems: 'center',
      }}
    >
      {completed ? (
        <Check sx={{ fontSize: 18 }} />
      ) : (
        <Box
          sx={{
            width: 8,
            height: 8,
            borderRadius: '50%',
            backgroundColor: 'currentColor',
          }}
        />
      )}
    </Box>
  );
}

<Stepper alternativeLabel activeStep={activeStep} connector={<QontoConnector />}>
  {steps.map((label) => (
    <Step key={label}>
      <StepLabel StepIconComponent={QontoStepIcon}>{label}</StepLabel>
    </Step>
  ))}
</Stepper>

MobileStepper (Dots / Progress / Text)

import MobileStepper from '@mui/material/MobileStepper';
import KeyboardArrowLeft from '@mui/icons-material/KeyboardArrowLeft';
import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight';

<MobileStepper
  variant="dots"          // 'dots' | 'progress' | 'text'
  steps={maxSteps}
  position="static"       // 'static' | 'top' | 'bottom'
  activeStep={activeStep}
  nextButton={
    <Button size="small" onClick={handleNext} disabled={activeStep === maxSteps - 1}>
      Next <KeyboardArrowRight />
    </Button>
  }
  backButton={
    <Button size="small" onClick={handleBack} disabled={activeStep === 0}>
      <KeyboardArrowLeft /> Back
    </Button>
  }
/>

Popover

import Popover from '@mui/material/Popover';

// Anchor to element
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);

<Button onClick={(e) => setAnchorEl(e.currentTarget)}>Open Popover</Button>
<Popover
  open={Boolean(anchorEl)}
  anchorEl={anchorEl}
  onClose={() => setAnchorEl(null)}
  anchorOrigin={{
    vertical: 'bottom',    // 'top' | 'center' | 'bottom' | number
    horizontal: 'left',    // 'left' | 'center' | 'right' | number
  }}
  transformOrigin={{
    vertical: 'top',
    horizontal: 'left',
  }}
  slotProps={{
    paper: {
      sx: { p: 2, maxWidth: 300 },
    },
  }}
>
  <Typography>Popover content</Typography>
</Popover>

// Mouse-follow popover
<Popover
  open={Boolean(anchorEl)}
  anchorReference="anchorPosition"
  anchorPosition={
    anchorEl ? { top: mouseY, left: mouseX } : undefined
  }
>
  <Typography>Follows cursor</Typography>
</Popover>

// Virtual element (e.g., text selection)
<Popover
  open={open}
  anchorEl={{
    getBoundingClientRect: () => ({
      top: selectionRect.top,
      left: selectionRect.left,
      bottom: selectionRect.bottom,
      right: selectionRect.right,
      width: selectionRect.width,
      height: selectionRect.height,
      x: selectionRect.x,
      y: selectionRect.y,
      toJSON: () => {},
    }),
  }}
>
  <FormattingToolbar />
</Popover>

Popper

Lower-level than Popover — no backdrop, no click-away handling built in.

import Popper from '@mui/material/Popper';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import Fade from '@mui/material/Fade';
import Paper from '@mui/material/Paper';

const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const open = Boolean(anchorEl);

<Button onClick={(e) => setAnchorEl(anchorEl ? null : e.currentTarget)}>
  Toggle
</Button>
<Popper
  open={open}
  anchorEl={anchorEl}
  placement="bottom-start"    // 12 placements: top, bottom, left, right + start/end
  transition
  modifiers={[
    { name: 'offset', options: { offset: [0, 8] } },       // [skid, distance]
    { name: 'flip', options: { fallbackPlacements: ['top-start'] } },
    { name: 'preventOverflow', options: { boundary: 'viewport' } },
  ]}
>
  {({ TransitionProps }) => (
    <Fade {...TransitionProps} timeout={200}>
      <Paper elevation={8} sx={{ p: 2, maxWidth: 300 }}>
        <ClickAwayListener onClickAway={() => setAnchorEl(null)}>
          <Box>
            <Typography>Popper content</Typography>
            <Button onClick={() => setAnchorEl(null)}>Close</Button>
          </Box>
        </ClickAwayListener>
      </Paper>
    </Fade>
  )}
</Popper>

Popper Placements

top-start    top    top-end
left-start            right-start
left                  right
left-end              right-end
bottom-start bottom bottom-end

Portal

Renders children into a different part of the DOM tree.

import Portal from '@mui/material/Portal';

// Render into document.body (default)
<Portal>
  <Box sx={{ position: 'fixed', bottom: 16, right: 16 }}>
    Floating element
  </Box>
</Portal>

// Render into a specific container
const containerRef = useRef<HTMLDivElement>(null);

<div ref={containerRef} />
<Portal container={containerRef.current}>
  <Typography>Rendered inside the div above</Typography>
</Portal>

// Disable portal (render in place)
<Portal disablePortal>
  <Typography>Rendered in the normal tree position</Typography>
</Portal>

ClickAwayListener

Detects clicks outside the wrapped element.

import ClickAwayListener from '@mui/material/ClickAwayListener';

<ClickAwayListener
  onClickAway={handleClose}
  mouseEvent="onMouseDown"    // 'onClick' | 'onMouseDown' | 'onMouseUp' | false
  touchEvent="onTouchStart"   // 'onTouchStart' | 'onTouchEnd' | false
>
  <Box sx={{ position: 'relative' }}>
    <Button onClick={toggleMenu}>Menu</Button>
    {open && (
      <Paper sx={{ position: 'absolute', top: '100%', zIndex: 1 }}>
        <MenuItem>Item 1</MenuItem>
        <MenuItem>Item 2</MenuItem>
      </Paper>
    )}
  </Box>
</ClickAwayListener>

Gotcha: ClickAwayListener only works with a single child element. If wrapping multiple elements, wrap them in a <div> or <Box>.


NoSsr

Defers rendering to client only — useful for client-dependent content.

import NoSsr from '@mui/material/NoSsr';

// Content only renders on client (prevents SSR hydration mismatch)
<NoSsr fallback={<Skeleton variant="rectangular" height={200} />}>
  <MapComponent /> {/* Uses window.navigator, fails on server */}
</NoSsr>

// Defer to second frame (avoid blocking first paint)
<NoSsr defer>
  <HeavyChart />
</NoSsr>

Composition Patterns

Component Prop (Polymorphic)

Many MUI components accept a component prop to change the rendered HTML element:

// Button as a router link
import { Link as RouterLink } from 'react-router-dom';

<Button component={RouterLink} to="/dashboard">
  Dashboard
</Button>

// ListItemButton as a router link
<ListItemButton component={RouterLink} to="/settings">
  <ListItemIcon><SettingsIcon /></ListItemIcon>
  <ListItemText primary="Settings" />
</ListItemButton>

// Card as an article
<Card component="article">
  <CardContent>Article content</CardContent>
</Card>

// Typography as a label
<Typography component="label" htmlFor="email-input">
  Email
</Typography>

Forwarding sx to Inner Components

interface CustomCardProps {
  title: string;
  children: React.ReactNode;
  sx?: SxProps<Theme>;
}

function CustomCard({ title, children, sx }: CustomCardProps) {
  return (
    <Card sx={[{ borderRadius: 2, border: '1px solid', borderColor: 'divider' }, ...(Array.isArray(sx) ? sx : [sx])]}>
      <CardContent>
        <Typography variant="h6">{title}</Typography>
        {children}
      </CardContent>
    </Card>
  );
}

// Usage — sx is properly merged
<CustomCard title="Stats" sx={{ bgcolor: 'primary.light' }}>
  Content
</CustomCard>

Render Props with MUI (Headless Patterns)

import { useAutocomplete } from '@mui/material/useAutocomplete';

function CustomCombobox({ options, label }: { options: string[]; label: string }) {
  const {
    getRootProps,
    getInputProps,
    getListboxProps,
    getOptionProps,
    groupedOptions,
    focused,
    value,
  } = useAutocomplete({
    options,
    getOptionLabel: (option) => option,
  });

  return (
    <div {...getRootProps()}>
      <label>{label}</label>
      <input {...getInputProps()} />
      {groupedOptions.length > 0 && (
        <ul {...getListboxProps()}>
          {groupedOptions.map((option, index) => (
            <li {...getOptionProps({ option, index })} key={option}>
              {option}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}