A Popper can be used to display some content on top of another. It's an alternative to react-popper.
Some important features of the Popper component:
disablePortal
.anchorEl
is passed as the reference object to create a new Popper.js
instance.import * as React from 'react';
import Box from '@mui/material/Box';
import Popper from '@mui/material/Popper';
export default function SimplePopper() {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(anchorEl ? null : event.currentTarget);
};
const open = Boolean(anchorEl);
const id = open ? 'simple-popper' : undefined;
return (
<div>
<button aria-describedby={id} type="button" onClick={handleClick}>
Toggle Popper
</button>
<Popper id={id} open={open} anchorEl={anchorEl}>
<Box sx={{ border: 1, p: 1, bgcolor: 'background.paper' }}>
The content of the Popper.
</Box>
</Popper>
</div>
);
}
The open/close state of the popper can be animated with a render prop child and a transition component. This component should respect the following conditions:
onEnter
callback prop when the enter transition starts.onExited
callback prop when the exit transition is completed.
These two callbacks allow the popper to unmount the child content when closed and fully transitioned.Popper has built-in support for react-transition-group.
import * as React from 'react';
import Box from '@mui/material/Box';
import Popper from '@mui/material/Popper';
import Fade from '@mui/material/Fade';
export default function TransitionsPopper() {
const [open, setOpen] = React.useState(false);
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
setOpen((previousOpen) => !previousOpen);
};
const canBeOpen = open && Boolean(anchorEl);
const id = canBeOpen ? 'transition-popper' : undefined;
return (
<div>
<button aria-describedby={id} type="button" onClick={handleClick}>
Toggle Popper
</button>
<Popper id={id} open={open} anchorEl={anchorEl} transition>
{({ TransitionProps }) => (
<Fade {...TransitionProps} timeout={350}>
<Box sx={{ border: 1, p: 1, bgcolor: 'background.paper' }}>
The content of the Popper.
</Box>
</Fade>
)}
</Popper>
</div>
);
}
Alternatively, you can use react-spring.
import * as React from 'react';
import Box from '@mui/material/Box';
import Popper from '@mui/material/Popper';
import { useSpring, animated } from '@react-spring/web';
interface FadeProps {
children?: React.ReactElement<unknown>;
in?: boolean;
onEnter?: () => void;
onExited?: () => void;
}
const Fade = React.forwardRef<HTMLDivElement, FadeProps>(function Fade(props, ref) {
const { in: open, children, onEnter, onExited, ...other } = props;
const style = useSpring({
from: { opacity: 0 },
to: { opacity: open ? 1 : 0 },
onStart: () => {
if (open && onEnter) {
onEnter();
}
},
onRest: () => {
if (!open && onExited) {
onExited();
}
},
});
return (
<animated.div ref={ref} style={style} {...other}>
{children}
</animated.div>
);
});
export default function SpringPopper() {
const [open, setOpen] = React.useState(false);
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
setOpen((previousOpen) => !previousOpen);
};
const canBeOpen = open && Boolean(anchorEl);
const id = canBeOpen ? 'spring-popper' : undefined;
return (
<div>
<button aria-describedby={id} type="button" onClick={handleClick}>
Toggle Popper
</button>
<Popper id={id} open={open} anchorEl={anchorEl} transition>
{({ TransitionProps }) => (
<Fade {...TransitionProps}>
<Box sx={{ border: 1, p: 1, bgcolor: 'background.paper' }}>
The content of the Popper.
</Box>
</Fade>
)}
</Popper>
</div>
);
}
import * as React from 'react';
import Box from '@mui/material/Box';
import Popper, { PopperPlacementType } from '@mui/material/Popper';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Grid';
import Button from '@mui/material/Button';
import Fade from '@mui/material/Fade';
import Paper from '@mui/material/Paper';
export default function PositionedPopper() {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
const [open, setOpen] = React.useState(false);
const [placement, setPlacement] = React.useState<PopperPlacementType>();
const handleClick =
(newPlacement: PopperPlacementType) =>
(event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
setOpen((prev) => placement !== newPlacement || !prev);
setPlacement(newPlacement);
};
return (
<Box sx={{ width: 500 }}>
<Popper
// Note: The following zIndex style is specifically for documentation purposes and may not be necessary in your application.
sx={{ zIndex: 1200 }}
open={open}
anchorEl={anchorEl}
placement={placement}
transition
>
{({ TransitionProps }) => (
<Fade {...TransitionProps} timeout={350}>
<Paper>
<Typography sx={{ p: 2 }}>The content of the Popper.</Typography>
</Paper>
</Fade>
)}
</Popper>
<Grid container sx={{ justifyContent: 'center' }}>
<Grid item>
<Button onClick={handleClick('top-start')}>top-start</Button>
<Button onClick={handleClick('top')}>top</Button>
<Button onClick={handleClick('top-end')}>top-end</Button>
</Grid>
</Grid>
<Grid container sx={{ justifyContent: 'center' }}>
<Grid item xs={6}>
<Button onClick={handleClick('left-start')}>left-start</Button>
<br />
<Button onClick={handleClick('left')}>left</Button>
<br />
<Button onClick={handleClick('left-end')}>left-end</Button>
</Grid>
<Grid
item
container
xs={6}
direction="column"
sx={{ alignItems: 'flex-end' }}
>
<Grid item>
<Button onClick={handleClick('right-start')}>right-start</Button>
</Grid>
<Grid item>
<Button onClick={handleClick('right')}>right</Button>
</Grid>
<Grid item>
<Button onClick={handleClick('right-end')}>right-end</Button>
</Grid>
</Grid>
</Grid>
<Grid container sx={{ justifyContent: 'center' }}>
<Grid item>
<Button onClick={handleClick('bottom-start')}>bottom-start</Button>
<Button onClick={handleClick('bottom')}>bottom</Button>
<Button onClick={handleClick('bottom-end')}>bottom-end</Button>
</Grid>
</Grid>
</Box>
);
}
import * as React from 'react';
import { styled } from '@mui/material/styles';
import FormControlLabel from '@mui/material/FormControlLabel';
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import MuiPopper from '@mui/material/Popper';
import Paper from '@mui/material/Paper';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import Switch from '@mui/material/Switch';
import TextField from '@mui/material/TextField';
import FormGroup from '@mui/material/FormGroup';
import { HighlightedCode } from '@mui/docs/HighlightedCode';
const Popper = styled(MuiPopper, {
shouldForwardProp: (prop) => prop !== 'arrow',
})(({ theme }) => ({
zIndex: 1,
'& > div': {
position: 'relative',
},
'&[data-popper-placement*="bottom"]': {
'& .MuiPopper-arrow': {
top: 0,
left: 0,
marginTop: '-0.9em',
width: '3em',
height: '1em',
'&::before': {
borderWidth: '0 1em 1em 1em',
borderColor: `transparent transparent ${theme.palette.background.paper} transparent`,
},
},
},
'&[data-popper-placement*="top"]': {
'& .MuiPopper-arrow': {
bottom: 0,
left: 0,
marginBottom: '-0.9em',
width: '3em',
height: '1em',
'&::before': {
borderWidth: '1em 1em 0 1em',
borderColor: `${theme.palette.background.paper} transparent transparent transparent`,
},
},
},
'&[data-popper-placement*="right"]': {
'& .MuiPopper-arrow': {
left: 0,
marginLeft: '-0.9em',
height: '3em',
width: '1em',
'&::before': {
borderWidth: '1em 1em 1em 0',
borderColor: `transparent ${theme.palette.background.paper} transparent transparent`,
},
},
},
'&[data-popper-placement*="left"]': {
'& .MuiPopper-arrow': {
right: 0,
marginRight: '-0.9em',
height: '3em',
width: '1em',
'&::before': {
borderWidth: '1em 0 1em 1em',
borderColor: `transparent transparent transparent ${theme.palette.background.paper}`,
},
},
},
variants: [
{
props: ({ arrow }) => arrow,
style: {
'&[data-popper-placement*="bottom"]': {
'& > div': {
marginTop: 2,
},
},
},
},
{
props: ({ arrow }) => !arrow,
style: {
'&[data-popper-placement*="bottom"]': {
'& > div': {
marginTop: 0,
},
},
},
},
{
props: ({ arrow }) => arrow,
style: {
'&[data-popper-placement*="top"]': {
'& > div': {
marginBottom: 2,
},
},
},
},
{
props: ({ arrow }) => !arrow,
style: {
'&[data-popper-placement*="top"]': {
'& > div': {
marginBottom: 0,
},
},
},
},
{
props: ({ arrow }) => arrow,
style: {
'&[data-popper-placement*="right"]': {
'& > div': {
marginLeft: 2,
},
},
},
},
{
props: ({ arrow }) => !arrow,
style: {
'&[data-popper-placement*="right"]': {
'& > div': {
marginLeft: 0,
},
},
},
},
{
props: ({ arrow }) => arrow,
style: {
'&[data-popper-placement*="left"]': {
'& > div': {
marginRight: 2,
},
},
},
},
{
props: ({ arrow }) => !arrow,
style: {
'&[data-popper-placement*="left"]': {
'& > div': {
marginRight: 0,
},
},
},
},
],
}));
const Arrow = styled('div')({
position: 'absolute',
fontSize: 7,
width: '3em',
height: '3em',
'&::before': {
content: '""',
margin: 'auto',
display: 'block',
width: 0,
height: 0,
borderStyle: 'solid',
},
});
export default function ScrollPlayground() {
const anchorRef = React.useRef(null);
const [open, setOpen] = React.useState(false);
const [placement, setPlacement] = React.useState('bottom');
const [disablePortal, setDisablePortal] = React.useState(false);
const [flip, setFlip] = React.useState({
enabled: true,
altBoundary: true,
rootBoundary: 'document',
});
const [preventOverflow, setPreventOverflow] = React.useState({
enabled: true,
altAxis: true,
altBoundary: true,
tether: true,
rootBoundary: 'document',
});
const [arrow, setArrow] = React.useState(false);
const [arrowRef, setArrowRef] = React.useState(null);
const handleClickButton = () => {
setOpen((prevOpen) => !prevOpen);
};
const centerScroll = (element) => {
if (!element) {
return;
}
const container = element.parentElement;
container.scrollTop = element.clientHeight / 4;
container.scrollLeft = element.clientWidth / 4;
};
const jsx = `
<Popper
placement="${placement}"
disablePortal={${disablePortal}}
modifiers={[
{
name: 'flip',
enabled: ${flip.enabled},
options: {
altBoundary: ${flip.altBoundary},
rootBoundary: '${flip.rootBoundary}',
padding: 8,
},
},
{
name: 'preventOverflow',
enabled: ${preventOverflow.enabled},
options: {
altAxis: ${preventOverflow.altAxis},
altBoundary: ${preventOverflow.altBoundary},
tether: ${preventOverflow.tether},
rootBoundary: '${preventOverflow.rootBoundary}',
padding: 8,
},
},
{
name: 'arrow',
enabled: ${arrow},
options: {
element: arrowRef,
},
},
]}
>
`;
const id = open ? 'scroll-playground' : null;
return (
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ height: 400, overflow: 'auto', mb: 3 }}>
<Grid
container
ref={centerScroll}
sx={{
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
width: '230%',
bgcolor: 'background.paper',
height: '230%',
}}
>
<div>
<Button
ref={anchorRef}
variant="contained"
onClick={handleClickButton}
aria-describedby={id}
>
Toggle Popper
</Button>
<Typography sx={{ mt: 2, maxWidth: 300 }}>
Scroll around this container to experiment with flip and
preventOverflow modifiers.
</Typography>
<Popper
id={id}
open={open}
arrow={arrow}
anchorEl={anchorRef.current}
placement={placement}
disablePortal={disablePortal}
modifiers={[
{
name: 'flip',
enabled: flip.enabled,
options: {
altBoundary: flip.altBoundary,
rootBoundary: flip.rootBoundary,
padding: 8,
},
},
{
name: 'preventOverflow',
enabled: preventOverflow.enabled,
options: {
altAxis: preventOverflow.altAxis,
altBoundary: preventOverflow.altBoundary,
tether: preventOverflow.tether,
rootBoundary: preventOverflow.rootBoundary,
padding: 8,
},
},
{
name: 'arrow',
enabled: arrow,
options: {
element: arrowRef,
},
},
]}
>
<div>
{arrow ? (
<Arrow ref={setArrowRef} className="MuiPopper-arrow" />
) : null}
<Paper sx={{ maxWidth: 400, overflow: 'auto' }}>
<DialogTitle>{"Use Google's location service?"}</DialogTitle>
<DialogContent>
<DialogContentText>
Let Google help apps determine location.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClickButton}>Disagree</Button>
<Button onClick={handleClickButton}>Agree</Button>
</DialogActions>
</Paper>
</div>
</Popper>
</div>
</Grid>
</Box>
<Grid container spacing={2}>
<Grid container item xs={12}>
<Grid item xs={12}>
<Typography variant="h6" component="div">
Appearance
</Typography>
</Grid>
<Grid item xs={6}>
<TextField
margin="dense"
sx={{ width: 200 }}
label="Placement"
select
value={placement}
onChange={(event) => {
setPlacement(event.target.value);
}}
variant="standard"
slotProps={{
select: {
native: true,
inputProps: {
'aria-labelledby': 'scroll-playground-placement-label',
},
},
inputLabel: {
id: 'scroll-playground-placement-label',
},
}}
>
<option value="top-start">top-start</option>
<option value="top">top</option>
<option value="top-end">top-end</option>
<option value="left-start">left-start</option>
<option value="left">left</option>
<option value="left-end">left-end</option>
<option value="right-start">right-start</option>
<option value="right">right</option>
<option value="right-end">right-end</option>
<option value="bottom-start">bottom-start</option>
<option value="bottom">bottom</option>
<option value="bottom-end">bottom-end</option>
</TextField>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
checked={disablePortal}
onChange={(event) => {
setDisablePortal(event.target.checked);
}}
value="disablePortal"
/>
}
label="Disable portal"
/>
<Typography
variant="caption"
sx={{ display: 'block', color: 'text.secondary' }}
>
(the children stay within their parent DOM hierarchy)
</Typography>
</Grid>
</Grid>
<Grid item xs={12}>
<Typography variant="h6" component="div">
Modifiers (options from Popper.js)
</Typography>
</Grid>
<Grid container item xs={12} spacing={1}>
<Grid item xs={6}>
<FormGroup>
<Typography variant="subtitle1">Prevent Overflow</Typography>
<FormControlLabel
control={
<Switch
checked={preventOverflow.enabled}
onChange={(event) => {
setPreventOverflow((old) => ({
...old,
enabled: event.target.checked,
}));
}}
value="arrow"
/>
}
label="Enable"
/>
<FormControlLabel
control={
<Switch
checked={preventOverflow.altAxis}
onChange={(event) => {
setPreventOverflow((old) => ({
...old,
altAxis: event.target.checked,
}));
}}
value="alt-axis"
/>
}
label="Alt axis"
/>
<FormControlLabel
control={
<Switch
checked={preventOverflow.altBoundary}
onChange={(event) => {
setPreventOverflow((old) => ({
...old,
altBoundary: event.target.checked,
}));
}}
value="alt-boundary"
/>
}
label="Alt Boundary"
/>
<FormControlLabel
control={
<Switch
checked={preventOverflow.tether}
onChange={(event) => {
setPreventOverflow((old) => ({
...old,
tether: event.target.checked,
}));
}}
value="tether"
/>
}
label="Tether"
/>
<TextField
margin="dense"
size="small"
label="Root Boundary"
select
value={preventOverflow.rootBoundary}
onChange={(event) => {
setPreventOverflow((old) => ({
...old,
rootBoundary: event.target.value,
}));
}}
variant="standard"
slotProps={{
select: {
native: true,
inputProps: {
'aria-labelledby':
'scroll-playground-prevent-overflow-root-boundary',
},
},
inputLabel: {
id: 'scroll-playground-prevent-overflow-root-boundary',
},
}}
>
<option value="document">document</option>
<option value="viewport">viewport</option>
</TextField>
</FormGroup>
</Grid>
<Grid item xs={6}>
<FormGroup>
<Typography variant="subtitle1">Flip</Typography>
<FormControlLabel
control={
<Switch
checked={flip.enabled}
onChange={(event) => {
setFlip((old) => ({
...old,
enabled: event.target.checked,
}));
}}
value="enabled"
/>
}
label="Enable"
/>
<FormControlLabel
control={
<Switch
checked={flip.altBoundary}
onChange={(event) => {
setFlip((old) => ({
...old,
altBoundary: event.target.checked,
}));
}}
value="alt-boundary"
/>
}
label="Alt Boundary"
/>
<TextField
margin="dense"
size="small"
label="Root Boundary"
select
value={flip.rootBoundary}
onChange={(event) => {
setFlip((old) => ({
...old,
rootBoundary: event.target.value,
}));
}}
variant="standard"
slotProps={{
select: {
native: true,
inputProps: {
'aria-labelledby': 'scroll-playground-flip-root-boundary',
},
},
inputLabel: {
id: 'scroll-playground-flip-root-boundary',
},
}}
>
<option value="document">document</option>
<option value="viewport">viewport</option>
</TextField>
</FormGroup>
<FormGroup>
<Typography variant="subtitle1">Arrow</Typography>
<FormControlLabel
control={
<Switch
checked={arrow}
onChange={(event) => {
setArrow(event.target.checked);
}}
value="arrow"
/>
}
label="Enable"
/>
</FormGroup>
</Grid>
</Grid>
</Grid>
<HighlightedCode code={jsx} language="jsx" />
</Box>
);
}
The value of the anchorEl
prop can be a reference to a fake DOM element.
You need to create an object shaped like the VirtualElement
.
Highlight part of the text to see the popper:
import * as React from 'react';
import Popper, { PopperProps } from '@mui/material/Popper';
import Typography from '@mui/material/Typography';
import Fade from '@mui/material/Fade';
import Paper from '@mui/material/Paper';
export default function VirtualElementPopper() {
const [open, setOpen] = React.useState(false);
const [anchorEl, setAnchorEl] = React.useState<PopperProps['anchorEl']>(null);
const previousAnchorElPosition = React.useRef<DOMRect>(undefined);
React.useEffect(() => {
if (anchorEl) {
if (typeof anchorEl === 'object') {
previousAnchorElPosition.current = anchorEl.getBoundingClientRect();
} else {
previousAnchorElPosition.current = anchorEl().getBoundingClientRect();
}
}
}, [anchorEl]);
const handleClose = () => {
setOpen(false);
};
const handleMouseUp = () => {
const selection = window.getSelection();
// Resets when the selection has a length of 0
if (!selection || selection.anchorOffset === selection.focusOffset) {
handleClose();
return;
}
const getBoundingClientRect = () => {
if (selection.rangeCount === 0 && previousAnchorElPosition.current) {
setOpen(false);
return previousAnchorElPosition.current;
}
return selection.getRangeAt(0).getBoundingClientRect();
};
setOpen(true);
setAnchorEl({ getBoundingClientRect });
};
const id = open ? 'virtual-element-popper' : undefined;
return (
<div onMouseLeave={handleClose}>
<Typography aria-describedby={id} onMouseUp={handleMouseUp}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam ipsum purus,
bibendum sit amet vulputate eget, porta semper ligula. Donec bibendum
vulputate erat, ac fringilla mi finibus nec. Donec ac dolor sed dolor
porttitor blandit vel vel purus. Fusce vel malesuada ligula. Nam quis
vehicula ante, eu finibus est. Proin ullamcorper fermentum orci, quis finibus
massa. Nunc lobortis, massa ut rutrum ultrices, metus metus finibus ex, sit
amet facilisis neque enim sed neque. Quisque accumsan metus vel maximus
consequat. Suspendisse lacinia tellus a libero volutpat maximus.
</Typography>
<Popper
id={id}
open={open}
anchorEl={anchorEl}
transition
placement="bottom-start"
>
{({ TransitionProps }) => (
<Fade {...TransitionProps} timeout={350}>
<Paper>
<Typography sx={{ p: 2 }}>The content of the Popper.</Typography>
</Paper>
</Fade>
)}
</Popper>
</div>
);
}
For more advanced use cases you might be able to take advantage of:
The package material-ui-popup-state
that takes care of popper state for you in most cases.
import * as React from 'react';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Popper from '@mui/material/Popper';
import PopupState, { bindToggle, bindPopper } from 'material-ui-popup-state';
import Fade from '@mui/material/Fade';
import Paper from '@mui/material/Paper';
export default function PopperPopupState() {
return (
<PopupState variant="popper" popupId="demo-popup-popper">
{(popupState) => (
<div>
<Button variant="contained" {...bindToggle(popupState)}>
Toggle Popper
</Button>
<Popper {...bindPopper(popupState)} transition>
{({ TransitionProps }) => (
<Fade {...TransitionProps} timeout={350}>
<Paper>
<Typography sx={{ p: 2 }}>The content of the Popper.</Typography>
</Paper>
</Fade>
)}
</Popper>
</div>
)}
</PopupState>
);
}