Skip to example

Skeleton Shimmer

Skeleton loading placeholders with a shimmer effect that transitions to revealed content using AnimateView. Skeletons are sized using placeholder data to reduce layout shifts.

React
>Ship this

Use this in your own site

This is not just a demo to learn from. Motion+ unlocks the full, production-ready source so you can copy it straight into your own project and ship it.

Source code

"use client"

import { AnimateView } from "motion/react-animate-view"
import { motion } from "motion/react"
import {
    CSSProperties,
    ReactNode,
    startTransition,
    useEffect,
    useState,
} from "react"

export default function SkeletonShimmer({
    shimmerDuration = 1.5,
    loadDelay = 2500,
}: {
    shimmerDuration?: number
    loadDelay?: number
}) {
    const [loaded, setLoaded] = useState(false)

    useEffect(() => {
        if (!loaded) {
            const timer = setTimeout(
                () => startTransition(() => setLoaded(true)),
                loadDelay
            )
            return () => clearTimeout(timer)
        }
    }, [loaded, loadDelay])

    return (
        <div style={containerStyle}>
            <style>{`
                @property --wipe {
                    syntax: '<percentage>';
                    inherits: true;
                    initial-value: -100%;
                }
                ::view-transition-group(skeleton-card) {
                    border-radius: 16px;
                    overflow: hidden;
                }
                ::view-transition-image-pair(skeleton-card) {
                    mix-blend-mode: normal;
                }
                ::view-transition-old(skeleton-card) {
                    /* AnimateView update currently animates old layers */
                    /* so we need to set the z-index to 2 to ensure it appears above the new layer */
                    z-index: 2;
                    mask-image: linear-gradient(to right, black var(--wipe), transparent calc(var(--wipe) + 100%));
                }
            `}</style>
            <AnimateView name="skeleton-card" update={wipeTransition}>
                {loaded ? (
                    <ProfileCard />
                ) : (
                    <SkeletonCard shimmerDuration={shimmerDuration} />
                )}
            </AnimateView>
            <button
                style={reloadBtnStyle}
                onClick={() => startTransition(() => setLoaded(false))}
            >
                Reload
            </button>
        </div>
    )
}

/** ==============   Views   ================ */

function ProfileCard() {
    return (
        <div style={cardStyle}>
            <div style={coverStyle} />
            <div style={profileAreaStyle}>
                <div style={avatarWrapStyle}>
                    <div style={avatarStyle}>
                            <MotionLogo />
                        </div>
                </div>
                <div style={infoGroupStyle}>
                    <h3 style={nameStyle}>{PROFILE_NAME}</h3>
                    <p style={handleStyle}>{PROFILE_HANDLE}</p>
                </div>
                <p style={bioStyle}>{PROFILE_BIO}</p>
                <div style={statsRowStyle}>
                    {STATS.map((stat) => (
                        <div key={stat.label} style={statItemStyle}>
                            <span style={statValueStyle}>{stat.value}</span>
                            <span style={statLabelStyle}>{stat.label}</span>
                        </div>
                    ))}
                </div>
                <button style={followBtnStyle}>Follow</button>
            </div>
        </div>
    )
}

function SkeletonCard({ shimmerDuration }: { shimmerDuration: number }) {
    return (
        <div style={cardStyle}>
            <Bone
                width="100%"
                height={COVER_HEIGHT}
                borderRadius={0}
                duration={shimmerDuration}
            />
            <div style={profileAreaStyle}>
                <div style={avatarWrapStyle}>
                    <div style={avatarRingStyle}>
                        <Bone
                            width={AVATAR_SIZE - 6}
                            height={AVATAR_SIZE - 6}
                            borderRadius="50%"
                            duration={shimmerDuration}
                        />
                    </div>
                </div>
                <Shimmer
                    duration={shimmerDuration}
                    borderRadius="6px"
                    style={fitContentStyle}
                >
                    <div style={infoGroupStyle}>
                        <h3 style={nameStyle}>{PROFILE_NAME}</h3>
                        <p style={handleStyle}>{PROFILE_HANDLE}</p>
                    </div>
                </Shimmer>
                <Shimmer duration={shimmerDuration} borderRadius="6px">
                    <p style={bioStyle}>{PROFILE_BIO}</p>
                </Shimmer>
                <div style={statsRowStyle}>
                    {STATS.map((stat) => (
                        <Shimmer
                            key={stat.label}
                            duration={shimmerDuration}
                            borderRadius="8px"
                            style={flexOneStyle}
                        >
                            <div style={statItemStyle}>
                                <span style={statValueStyle}>{stat.value}</span>
                                <span style={statLabelStyle}>{stat.label}</span>
                            </div>
                        </Shimmer>
                    ))}
                </div>
                <Shimmer duration={shimmerDuration} borderRadius="10px">
                    <div style={followSizerStyle}>Follow</div>
                </Shimmer>
            </div>
        </div>
    )
}

/** ==============   Components   ================ */

function MotionLogo() {
    return (
        <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 1260 454"
            fill="currentColor"
            style={{ width: "65%", height: "65%" }}
        >
            <path d="M475.753 0L226.8 453.6L0 453.6L194.392 99.4116C224.526 44.5081 299.724 0 362.353 0L475.753 0Z" />
            <path d="M1031.93 113.4C1031.93 50.7709 1082.7 0 1145.33 0C1207.96 0 1258.73 50.7709 1258.73 113.4C1258.73 176.029 1207.96 226.8 1145.33 226.8C1082.7 226.8 1031.93 176.029 1031.93 113.4Z" />
            <path d="M518.278 0L745.078 0L496.125 453.6L269.325 453.6L518.278 0Z" />
            <path d="M786.147 0L1012.95 0L818.555 354.188C788.422 409.092 713.223 453.6 650.594 453.6L537.194 453.6L786.147 0Z" />
        </svg>
    )
}

const Shimmer = ({
    duration,
    borderRadius = "6px",
    style,
    children,
}: {
    duration: number
    borderRadius?: string
    style?: CSSProperties
    children: ReactNode
}) => (
    <motion.div
        animate={shimmerAnimate}
        transition={{ duration, ease: "easeInOut", repeat: Infinity }}
        style={{
            borderRadius,
            background: SHIMMER_GRADIENT,
            backgroundSize: "200% 100%",
            overflow: "hidden",
            ...style,
        }}
    >
        <div style={shimmerSizerStyle}>{children}</div>
    </motion.div>
)

const shimmerSizerStyle: CSSProperties = { visibility: "hidden" }
const fitContentStyle: CSSProperties = { alignSelf: "flex-start" }
const flexOneStyle: CSSProperties = { flex: 1 }

const Bone = ({
    width,
    height,
    borderRadius = 6,
    duration,
}: {
    width: number | string
    height: number
    borderRadius?: number | string
    duration: number
}) => (
    <motion.div
        animate={shimmerAnimate}
        transition={{ duration, ease: "easeInOut", repeat: Infinity }}
        style={{
            width,
            height,
            borderRadius,
            background: SHIMMER_GRADIENT,
            backgroundSize: "200% 100%",
            flexShrink: 0,
        }}
    />
)

/** ==============   Animation   ================ */

const wipeTransition = {
    "--wipe": ["100%", "-100%"],
    transition: { duration: 0.6, ease: "easeInOut" as const },
}

/** ==============   Styles   ================ */

const COVER_HEIGHT = 120
const AVATAR_SIZE = 56
const AVATAR_OVERLAP = 28

const BONE_BASE = "rgba(255, 255, 255, 0.06)"
const BONE_HIGHLIGHT = "rgba(255, 255, 255, 0.12)"
const SHIMMER_GRADIENT = `linear-gradient(90deg, ${BONE_BASE} 25%, ${BONE_HIGHLIGHT} 50%, ${BONE_BASE} 75%)`
const shimmerAnimate = { backgroundPosition: ["-200% 0", "200% 0"] }

const containerStyle: CSSProperties = {
    width: "100%",
    minHeight: "100dvh",
    display: "flex",
    flexDirection: "column",
    alignItems: "center",
    justifyContent: "center",
    gap: 16,
    padding: 20,
    boxSizing: "border-box",
}

const cardStyle: CSSProperties = {
    width: "100%",
    maxWidth: 360,
    borderRadius: "16px",
    border: "1px solid var(--border)",
    backgroundColor: "var(--layer)",
    overflow: "hidden",
    boxShadow: "0 8px 32px rgba(0, 0, 0, 0.2)",
}

const coverStyle: CSSProperties = {
    width: "100%",
    height: COVER_HEIGHT,
    background: "linear-gradient(135deg, var(--hue-4) 0%, var(--hue-3) 100%)",
}

const profileAreaStyle: CSSProperties = {
    padding: "0 20px 20px",
    display: "flex",
    flexDirection: "column",
    gap: 14,
}

const avatarWrapStyle: CSSProperties = {
    marginTop: -AVATAR_OVERLAP,
}

const avatarRingStyle: CSSProperties = {
    width: AVATAR_SIZE,
    height: AVATAR_SIZE,
    borderRadius: "50%",
    backgroundColor: "var(--layer)",
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
}

const avatarStyle: CSSProperties = {
    width: AVATAR_SIZE,
    height: AVATAR_SIZE,
    borderRadius: "50%",
    background: "#f5e725",
    border: "3px solid var(--layer)",
    boxSizing: "border-box",
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    color: "#000",
}

const infoGroupStyle: CSSProperties = {
    display: "flex",
    flexDirection: "column",
    gap: 4,
}

const nameStyle: CSSProperties = {
    margin: 0,
    fontSize: 16,
    fontWeight: 600,
    color: "var(--white)",
    lineHeight: 1.2,
}

const handleStyle: CSSProperties = {
    margin: 0,
    fontSize: 13,
    color: "rgba(255, 255, 255, 0.45)",
    lineHeight: 1.2,
}

const bioStyle: CSSProperties = {
    margin: 0,
    fontSize: 13,
    lineHeight: 1.5,
    color: "rgba(255, 255, 255, 0.55)",
}

const statsRowStyle: CSSProperties = {
    display: "flex",
    gap: 8,
}

const statItemStyle: CSSProperties = {
    display: "flex",
    flexDirection: "column",
    alignItems: "center",
    padding: "8px 4px",
    borderRadius: "8px",
    backgroundColor: "rgba(255, 255, 255, 0.04)",
    flex: 1,
    gap: 2,
}

const statValueStyle: CSSProperties = {
    fontSize: 15,
    fontWeight: 600,
    color: "var(--white)",
    lineHeight: 1.3,
}

const statLabelStyle: CSSProperties = {
    fontSize: 11,
    color: "rgba(255, 255, 255, 0.35)",
    lineHeight: 1.3,
}

const followBaseStyle: CSSProperties = {
    width: "100%",
    padding: "10px 0",
    fontSize: 14,
    fontWeight: 600,
    fontFamily: "inherit",
}

const followSizerStyle: CSSProperties = {
    ...followBaseStyle,
    textAlign: "center",
}

const followBtnStyle: CSSProperties = {
    ...followBaseStyle,
    borderRadius: "10px",
    border: "none",
    background: "var(--hue-4)",
    color: "var(--white)",
    cursor: "pointer",
}

const reloadBtnStyle: CSSProperties = {
    border: "1px solid var(--border)",
    borderRadius: "10px",
    background: "transparent",
    color: "var(--white)",
    padding: "8px 20px",
    fontSize: 13,
    fontWeight: 500,
    fontFamily: "inherit",
    cursor: "pointer",
}

/** ==============   Data   ================ */

const PROFILE_NAME = "Motion"
const PROFILE_HANDLE = "@motiondotdev"
const PROFILE_BIO =
    "Free and open source. Create stunning web animations for React, JavaScript and Vue."

const STATS = [
    { value: "127", label: "Posts" },
    { value: "11K", label: "Followers" },
    { value: "5", label: "Following" },
]