Skeleton
Display a placeholder preview of your content before the data gets loaded to reduce load-time frustration.
The data for your components might not be immediately available. You can improve the perceived responsiveness of the page by using skeletons. It feels like things are happening immediately, then the information is incrementally displayed on the screen (Cf. Avoid The Spinner).
Usage
The component is designed to be used directly in your components. For instance:
if (!is.null(item)) {
tags$img(style = "width: 210px; height: 118px;", alt = item$title, src = item$src)
} else {
Skeleton(variant = "rectangular", width = 210, height = 118)
}In a Shiny app, a skeleton is a good placeholder while an output is computed: see Skeletons in Shiny apps.
Variants
The component supports 4 shape variants:
-
text(default): represents a single line of text (you can adjust the height via font size). -
circular,rectangular, androunded: come with different border radius to let you take control of the size.
muiMaterialPage(
CssBaseline(),
Stack(
spacing = 1,
# For variant = "text", adjust the height via font-size
Skeleton(variant = "text", sx = list(fontSize = "1rem")),
# For other variants, adjust the size with `width` and `height`
Skeleton(variant = "circular", width = 40, height = 40),
Skeleton(variant = "rectangular", width = 210, height = 60),
Skeleton(variant = "rounded", width = 210, height = 60)
)
)JS code
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
export default function Variants() {
return (
<Stack spacing={1}>
{/* For variant="text", adjust the height via font-size */}
<Skeleton variant="text" sx={{ fontSize: '1rem' }} />
{/* For other variants, adjust the size with `width` and `height` */}
<Skeleton variant="circular" width={40} height={40} />
<Skeleton variant="rectangular" width={210} height={60} />
<Skeleton variant="rounded" width={210} height={60} />
</Stack>
);
}Animations
By default, the skeleton pulsates, but you can change the animation to a wave or disable it entirely.
muiMaterialPage(
CssBaseline(),
Box(sx = list(width = 300), Skeleton(), Skeleton(animation = "wave"), Skeleton(animation = FALSE))
)JS code
Pulsate example
videos <- list(
list(
src = "https://i.ytimg.com/vi/pLqipJNItIo/hqdefault.jpg?sqp=-oaymwEYCNIBEHZIVfKriqkDCwgBFQAAiEIYAXAB&rs=AOn4CLBkklsyaw9FxDmMKapyBYCn9tbPNQ",
title = "Don Diablo @ Tomorrowland Main Stage 2019 | Official…",
channel = "Don Diablo", views = "396k views", createdAt = "a week ago"
),
list(
src = "https://i.ytimg.com/vi/_Uu12zY01ts/hqdefault.jpg?sqp=-oaymwEZCPYBEIoBSFXyq4qpAwsIARUAAIhCGAFwAQ==&rs=AOn4CLCpX6Jan2rxrCAZxJYDXppTP4MoQA",
title = "Queen - Greatest Hits",
channel = "Queen Official", views = "40M views", createdAt = "3 years ago"
),
list(
src = "https://i.ytimg.com/vi/kkLk2XWMBf8/hqdefault.jpg?sqp=-oaymwEYCNIBEHZIVfKriqkDCwgBFQAAiEIYAXAB&rs=AOn4CLB4GZTFu1Ju2EPPPXnhMZtFVvYBaw",
title = "Calvin Harris, Sam Smith - Promises (Official Video)",
channel = "Calvin Harris", views = "130M views", createdAt = "10 months ago"
)
)
media <- function(loading = FALSE) {
Grid(
container = TRUE,
wrap = "nowrap",
lapply(videos, function(item) {
Box(
sx = list(width = 210, marginRight = 0.5, my = 5),
if (loading) {
Skeleton(variant = "rectangular", width = 210, height = 118)
} else {
tags$img(style = "width: 210px; height: 118px;", alt = item$title, src = item$src)
},
if (loading) {
Box(sx = list(pt = 0.5), Skeleton(), Skeleton(width = "60%"))
} else {
Box(
sx = list(pr = 2),
Typography(gutterBottom = TRUE, variant = "body2", item$title),
Typography(variant = "caption", sx = list(display = "block", color = "text.secondary"), item$channel),
Typography(variant = "caption", sx = list(color = "text.secondary"), paste(item$views, "•", item$createdAt))
)
}
)
})
)
}
muiMaterialPage(CssBaseline(), Box(sx = list(overflow = "hidden"), media(loading = TRUE), media()))JS code
import Grid from '@mui/material/Grid';
import PropTypes from 'prop-types';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Skeleton from '@mui/material/Skeleton';
const data = [
{
src: 'https://i.ytimg.com/vi/pLqipJNItIo/hqdefault.jpg?sqp=-oaymwEYCNIBEHZIVfKriqkDCwgBFQAAiEIYAXAB&rs=AOn4CLBkklsyaw9FxDmMKapyBYCn9tbPNQ',
title: 'Don Diablo @ Tomorrowland Main Stage 2019 | Official…',
channel: 'Don Diablo',
views: '396k views',
createdAt: 'a week ago',
},
{
src: 'https://i.ytimg.com/vi/_Uu12zY01ts/hqdefault.jpg?sqp=-oaymwEZCPYBEIoBSFXyq4qpAwsIARUAAIhCGAFwAQ==&rs=AOn4CLCpX6Jan2rxrCAZxJYDXppTP4MoQA',
title: 'Queen - Greatest Hits',
channel: 'Queen Official',
views: '40M views',
createdAt: '3 years ago',
},
{
src: 'https://i.ytimg.com/vi/kkLk2XWMBf8/hqdefault.jpg?sqp=-oaymwEYCNIBEHZIVfKriqkDCwgBFQAAiEIYAXAB&rs=AOn4CLB4GZTFu1Ju2EPPPXnhMZtFVvYBaw',
title: 'Calvin Harris, Sam Smith - Promises (Official Video)',
channel: 'Calvin Harris',
views: '130M views',
createdAt: '10 months ago',
},
];
function Media(props) {
const { loading = false } = props;
return (
<Grid container wrap="nowrap">
{(loading ? Array.from(new Array(3)) : data).map((item, index) => (
<Box key={index} sx={{ width: 210, marginRight: 0.5, my: 5 }}>
{item ? (
<img
style={{ width: 210, height: 118 }}
alt={item.title}
src={item.src}
/>
) : (
<Skeleton variant="rectangular" width={210} height={118} />
)}
{item ? (
<Box sx={{ pr: 2 }}>
<Typography gutterBottom variant="body2">
{item.title}
</Typography>
<Typography
variant="caption"
sx={{ display: 'block', color: 'text.secondary' }}
>
{item.channel}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{`${item.views} • ${item.createdAt}`}
</Typography>
</Box>
) : (
<Box sx={{ pt: 0.5 }}>
<Skeleton />
<Skeleton width="60%" />
</Box>
)}
</Box>
))}
</Grid>
);
}
Media.propTypes = {
loading: PropTypes.bool,
};
export default function YouTube() {
return (
<Box sx={{ overflow: 'hidden' }}>
<Media loading />
<Media />
</Box>
);
}Wave example
tedAvatar <- "https://pbs.twimg.com/profile_images/877631054525472768/Xp5FAPD5_reasonably_small.jpg"
tedImage <- "https://pi.tedcdn.com/r/talkstar-photos.s3.amazonaws.com/uploads/72bda89f-9bbf-4685-910a-2f151c4f3a8a/NicolaSturgeon_2019T-embed.jpg?w=512"
facebookMedia <- function(loading = FALSE) {
Card(
sx = list(maxWidth = 345, m = 2),
CardHeader(
avatar = if (loading) {
Skeleton(animation = "wave", variant = "circular", width = 40, height = 40)
} else {
Avatar(alt = "Ted talk", src = tedAvatar)
},
action = if (!loading) IconButton(`aria-label` = "settings", Icon("more_vert")),
title = if (loading) Skeleton(animation = "wave", height = 10, width = "80%", style = list(marginBottom = 6)) else "Ted",
subheader = if (loading) Skeleton(animation = "wave", height = 10, width = "40%") else "5 hours ago"
),
if (loading) {
Skeleton(sx = list(height = 190), animation = "wave", variant = "rectangular")
} else {
CardMedia(component = "img", height = "140", image = tedImage, alt = "Nicola Sturgeon on a TED talk stage")
},
CardContent(
if (loading) {
tagList(
Skeleton(animation = "wave", height = 10, style = list(marginBottom = 6)),
Skeleton(animation = "wave", height = 10, width = "80%")
)
} else {
Typography(
variant = "body2", component = "p", sx = list(color = "text.secondary"),
"Why First Minister of Scotland Nicola Sturgeon thinks GDP is the wrong measure of a country's success:"
)
}
)
)
}
muiMaterialPage(useMaterialIconsFilled = TRUE, CssBaseline(), Box(facebookMedia(loading = TRUE), facebookMedia()))JS code
import * as React from 'react';
import PropTypes from 'prop-types';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import CardContent from '@mui/material/CardContent';
import CardMedia from '@mui/material/CardMedia';
import Avatar from '@mui/material/Avatar';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import Skeleton from '@mui/material/Skeleton';
function Media(props) {
const { loading = false } = props;
return (
<Card sx={{ maxWidth: 345, m: 2 }}>
<CardHeader
avatar={
loading ? (
<Skeleton animation="wave" variant="circular" width={40} height={40} />
) : (
<Avatar
alt="Ted talk"
src="https://pbs.twimg.com/profile_images/877631054525472768/Xp5FAPD5_reasonably_small.jpg"
/>
)
}
action={
loading ? null : (
<IconButton aria-label="settings">
<MoreVertIcon />
</IconButton>
)
}
title={
loading ? (
<Skeleton
animation="wave"
height={10}
width="80%"
style={{ marginBottom: 6 }}
/>
) : (
'Ted'
)
}
subheader={
loading ? (
<Skeleton animation="wave" height={10} width="40%" />
) : (
'5 hours ago'
)
}
/>
{loading ? (
<Skeleton sx={{ height: 190 }} animation="wave" variant="rectangular" />
) : (
<CardMedia
component="img"
height="140"
image="https://pi.tedcdn.com/r/talkstar-photos.s3.amazonaws.com/uploads/72bda89f-9bbf-4685-910a-2f151c4f3a8a/NicolaSturgeon_2019T-embed.jpg?w=512"
alt="Nicola Sturgeon on a TED talk stage"
/>
)}
<CardContent>
{loading ? (
<React.Fragment>
<Skeleton animation="wave" height={10} style={{ marginBottom: 6 }} />
<Skeleton animation="wave" height={10} width="80%" />
</React.Fragment>
) : (
<Typography variant="body2" component="p" sx={{ color: 'text.secondary' }}>
{
"Why First Minister of Scotland Nicola Sturgeon thinks GDP is the wrong measure of a country's success:"
}
</Typography>
)}
</CardContent>
</Card>
);
}
Media.propTypes = {
loading: PropTypes.bool,
};
export default function Facebook() {
return (
<div>
<Media loading />
<Media />
</div>
);
}Inferring dimensions
In addition to accepting width and height
props, the component can also infer the dimensions.
It works well when it comes to typography as its height is set using
em units.
Typography(variant = "h1", if (loading) Skeleton() else "h1")
typographyDemo <- function(loading = FALSE) {
Box(lapply(c("h1", "h3", "body1", "caption"), function(variant) {
Typography(component = "div", variant = variant, if (loading) Skeleton() else variant)
}))
}
muiMaterialPage(
CssBaseline(),
Grid(container = TRUE, spacing = 8, Grid(size = "grow", typographyDemo(loading = TRUE)), Grid(size = "grow", typographyDemo()))
)JS code
import Typography from '@mui/material/Typography';
import PropTypes from 'prop-types';
import Skeleton from '@mui/material/Skeleton';
import Grid from '@mui/material/Grid';
const variants = ['h1', 'h3', 'body1', 'caption'];
function TypographyDemo(props) {
const { loading = false } = props;
return (
<div>
{variants.map((variant) => (
<Typography component="div" key={variant} variant={variant}>
{loading ? <Skeleton /> : variant}
</Typography>
))}
</div>
);
}
TypographyDemo.propTypes = {
loading: PropTypes.bool,
};
export default function SkeletonTypography() {
return (
<Grid container spacing={8}>
<Grid size="grow">
<TypographyDemo loading />
</Grid>
<Grid size="grow">
<TypographyDemo />
</Grid>
</Grid>
);
}But when it comes to other components, you may not want to repeat the
width and height. In these instances, you can pass children
and it will infer its width and height from them.
skeletonChildrenDemo <- function(loading = FALSE) {
Box(
Box(
sx = list(display = "flex", alignItems = "center"),
Box(sx = list(margin = 1), if (loading) Skeleton(variant = "circular", Avatar()) else Avatar(src = tedAvatar)),
Box(sx = list(width = "100%"), if (loading) Skeleton(width = "100%", Typography(".")) else Typography("Ted"))
),
if (loading) {
Skeleton(variant = "rectangular", width = "100%", tags$div(style = "padding-top: 57%;"))
} else {
tags$img(src = tedImage, alt = "", style = "width: 100%;")
}
)
}
muiMaterialPage(
CssBaseline(),
Grid(container = TRUE, spacing = 8, Grid(size = "grow", skeletonChildrenDemo(loading = TRUE)), Grid(size = "grow", skeletonChildrenDemo()))
)JS code
import { styled } from '@mui/material/styles';
import PropTypes from 'prop-types';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Avatar from '@mui/material/Avatar';
import Grid from '@mui/material/Grid';
import Skeleton from '@mui/material/Skeleton';
const Image = styled('img')({
width: '100%',
});
function SkeletonChildrenDemo(props) {
const { loading = false } = props;
return (
<div>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box sx={{ margin: 1 }}>
{loading ? (
<Skeleton variant="circular">
<Avatar />
</Skeleton>
) : (
<Avatar src="https://pbs.twimg.com/profile_images/877631054525472768/Xp5FAPD5_reasonably_small.jpg" />
)}
</Box>
<Box sx={{ width: '100%' }}>
{loading ? (
<Skeleton width="100%">
<Typography>.</Typography>
</Skeleton>
) : (
<Typography>Ted</Typography>
)}
</Box>
</Box>
{loading ? (
<Skeleton variant="rectangular" width="100%">
<div style={{ paddingTop: '57%' }} />
</Skeleton>
) : (
<Image
src="https://pi.tedcdn.com/r/talkstar-photos.s3.amazonaws.com/uploads/72bda89f-9bbf-4685-910a-2f151c4f3a8a/NicolaSturgeon_2019T-embed.jpg?w=512"
alt=""
/>
)}
</div>
);
}
SkeletonChildrenDemo.propTypes = {
loading: PropTypes.bool,
};
export default function SkeletonChildren() {
return (
<Grid container spacing={8}>
<Grid size="grow">
<SkeletonChildrenDemo loading />
</Grid>
<Grid size="grow">
<SkeletonChildrenDemo />
</Grid>
</Grid>
);
}Color
The color of the component can be customized by changing its
background-color CSS property. This is especially useful
when on a black background (as the skeleton will otherwise be
invisible).
muiMaterialPage(
CssBaseline(),
Box(
sx = list(bgcolor = "#121212", p = 8, width = "100%", display = "flex", justifyContent = "center"),
Skeleton(sx = list(bgcolor = "grey.900"), variant = "rectangular", width = 210, height = 118)
)
)JS code
import Skeleton from '@mui/material/Skeleton';
import Box from '@mui/material/Box';
export default function SkeletonColor() {
return (
<Box
sx={{
bgcolor: '#121212',
p: 8,
width: '100%',
display: 'flex',
justifyContent: 'center',
}}
>
<Skeleton
sx={{ bgcolor: 'grey.900' }}
variant="rectangular"
width={210}
height={118}
/>
</Box>
);
}Accessibility
Skeleton screens provide an alternative to the traditional spinner method. Rather than showing an abstract widget, skeleton screens create anticipation of what is to come and reduce cognitive load.
The background color of the skeleton uses the least amount of luminance to be visible in good conditions (good ambient light, good screen, no visual impairments).
Skeletons in Shiny apps
Render the skeleton as the initial content of a
reactOutput(), then replace it when the data is ready.
renderReact() re-renders the output when its reactive
dependencies change:
library(shiny)
library(muiMaterial)
ui <- muiMaterialPage(
CssBaseline(),
Box(sx = list(p = 2, maxWidth = 400), reactOutput("summary"))
)
server <- function(input, output, session) {
ready <- reactiveVal(FALSE)
later::later(function() ready(TRUE), 2) # stands for a slow data load
output$summary <- renderReact({
if (!ready()) {
Stack(spacing = 1, Skeleton(variant = "text", sx = list(fontSize = "2rem")), Skeleton(variant = "rounded", height = 120))
} else {
Card(CardContent(Typography(variant = "h5", "Summary"), Typography("The data has loaded.")))
}
})
}
shinyApp(ui, server)