uui
componentsnapchat-loadersource: ok · refreshed
Snapchat Loader
npx shadcn add "https://uui.app/r/snapchat-loader.json"free
Where it goes
chat
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files2 · source cached 10/8/2026, 8:59:10 AM
components/uui/snapchat-loader/example.tsx
'use client';
import * as React from 'react';
import { SnapchatLoader } from './snapchat-loader';
// ─── Page content ─────────────────────────────────────────────────────────────
function AppContent() {
return (
<div className="h-full flex items-center justify-center bg-background">
<p className="text-sm font-medium text-muted-foreground select-none">
Swipe down to see the loader
</p>
</div>
);
}
// ─── Example ─────────────────────────────────────────────────────────────────
export function SnapchatLoaderExample() {
return (
<div className="relative w-full h-svh">
<SnapchatLoader
maxPullHeight={120}
loadingDuration={2400}
accentColor="#1a1a1a"
resistance={0.45}
zoneClassName="bg-[#FEDD1A]"
>
<AppContent />
</SnapchatLoader>
</div>
);
}
export { SnapchatLoader } from './snapchat-loader';
components/uui/snapchat-loader/snapchat-loader.tsx
'use client';
import * as React from 'react';
// ─── Types ────────────────────────────────────────────────────────────────────
export type SnapchatLoaderPhase = 'idle' | 'dragging' | 'ready' | 'loading' | 'complete';
export interface SnapchatLoaderConfig {
/** Height (px) the pull zone expands to at max pull. Default: 120 */
maxPullHeight?: number;
/** Height (px) the zone shrinks to while shimmer plays. Default: 6 */
loadingHeight?: number;
/** How many ms the simulated loading takes. Default: 4000 */
loadingDuration?: number;
/** Resistance factor: how much the pull zone grows per drag pixel. Default: 0.45 */
resistance?: number;
/**
* Extra classes applied to the pull zone element.
* Use this to set the loader background color, e.g. `bg-[#FEDD1A]` or `bg-yellow-400`.
* Defaults to `bg-muted`.
*/
zoneClassName?: string;
}
export interface SnapchatLoaderCallbacks {
/** Fires every animation frame while the user is dragging, with current pull height */
onDrag?: (pullHeight: number) => void;
/** Fires when the user releases the pull at or beyond the threshold */
onRelease?: (pullHeight: number) => void;
/** Fires once the simulated loading animation finishes */
onLoadingComplete?: () => void;
/** Fires when the phase changes */
onPhaseChange?: (phase: SnapchatLoaderPhase) => void;
}
export interface SnapchatLoaderProps extends SnapchatLoaderConfig, SnapchatLoaderCallbacks {
className?: string;
children?: React.ReactNode;
}
// ─── Ghost body + faces ───────────────────────────────────────────────────────
// Rendered inside the overflow-hidden zone. The fixed-height inner wrapper
// anchors the ghost's bottom to maxPullHeight so it's revealed bottom-first.
function GhostBody({ phase }: { phase: SnapchatLoaderPhase }) {
const isWink = phase === 'ready';
return (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-6 pointer-events-none select-none">
<svg xmlns="http://www.w3.org/2000/svg" width="90" viewBox="0 0 277 233" fill="none">
{/* Body */}
<path
d="M236.185 229.306H39.9746V155.735L41.6035 154.898C43.9934 153.67 46.639 151.807 48.7715 149.556C50.921 147.286 52.3641 144.82 52.748 142.384L52.7588 142.317L52.7725 142.252C53.1629 140.335 52.8851 138.103 52.3379 135.386C51.8369 132.898 51.0399 129.763 50.9658 126.942C50.8362 122.01 50.4888 116.462 50.1406 110.534C49.794 104.631 49.4481 98.3823 49.335 92.1514C49.1101 79.7682 49.7877 67.0945 53.4004 57.0518L53.4131 57.0146L53.4277 56.9785C66.1565 24.2919 97.3945 4.10505 131.825 3.06934C151.474 2.47813 170.465 5.57882 187.797 15.6562H187.798C195.938 20.3905 204.056 27.909 210.593 36.4678C217.123 45.0169 222.243 54.8235 224.152 64.2314L224.402 65.5156C226.89 78.7956 226.27 92.3108 225.556 105.444C224.812 119.129 223.971 132.377 226.059 145.261H226.058C226.139 145.742 226.417 146.365 227.062 147.191C227.714 148.025 228.592 148.884 229.662 149.869C230.67 150.796 231.907 151.886 232.97 152.979C234.042 154.081 235.149 155.393 235.887 156.924L236.185 157.541V229.306Z"
fill="#FEFEFE"
stroke="black"
strokeWidth="6"
/>
{/* Normal Face — fades out when ready */}
<g style={{ opacity: isWink ? 0 : 1, transition: 'opacity 0.2s ease' }}>
<path
d="M184.691 95.1641C186.551 95.5511 188.831 96.4668 188.158 98.8131C185.431 108.328 173.289 117.033 164.282 119.983C158.875 121.754 145.516 124.53 139.966 124.577C126.159 124.695 108.893 120.647 98.0032 111.85C95.2437 109.624 92.7572 106.625 90.6974 104.006C87.7872 99.6003 86.0887 96.1533 92.9827 95.3486C94.4712 97.1606 96.3495 100.414 97.983 102.662C104.021 110.97 119.944 116.207 129.806 117.414C143.62 119.105 164.114 115.365 175.071 106.088C178.758 102.965 181.493 97.7151 184.691 95.1641Z"
fill="black"
/>
<path d="M94.3812 48.2254C108.988 46.9009 112.533 77.4851 95.717 80.3796C80.942 81.3614 77.2285 51.3444 94.3812 48.2254Z" fill="black" />
<path
d="M181.38 48C183.585 48.0775 185.273 48.532 187.178 49.7173C190.325 51.718 192.545 54.895 193.338 58.5405C195.208 67.1607 192.98 78.023 183.058 80.2345C166.602 80.6772 165.66 50.1512 181.38 48Z"
fill="black"
/>
</g>
{/* Wink Face — fades in when ready */}
<g style={{ opacity: isWink ? 1 : 0, transition: 'opacity 0.2s ease' }}>
<path d="M94.3812 48.2254C108.988 46.9009 112.533 77.4851 95.717 80.3796C80.942 81.3614 77.2285 51.3444 94.3812 48.2254Z" fill="black" />
<path
d="M93.4009 94.9766C96.0387 97.7548 97.9079 102.347 100.781 104.795C111.036 113.534 124.795 117.219 138.039 117.159C148.714 117.506 156.875 115.474 166.373 110.983C174.945 106.93 177.581 103.075 183.271 95.9116C184.168 94.7841 186.601 94.9373 187.161 95.8953C189.826 100.446 182.563 107.513 179.476 110.236C183.341 116.653 185.543 121.884 183.941 129.354C182.776 134.784 171.848 139.064 166.99 137.319C163.221 135.965 160.377 134.701 157.443 131.97C155.049 130.099 150.835 123.756 149.673 123.48C132.87 125.156 120.873 123.576 105.358 115.995C98.7439 112.221 91.9184 106.361 88.8512 99.2531C87.5042 96.1318 91.2927 95.4481 93.4009 94.9766Z"
fill="black"
/>
<path
d="M179.475 110.234C183.34 116.651 185.543 121.882 183.94 129.352C182.775 134.782 171.847 139.062 166.989 137.317C163.221 135.963 160.376 134.699 157.443 131.968C155.048 130.097 150.835 123.754 149.672 123.478C151.763 122.951 154.016 122.477 156.056 121.924C159.547 120.977 161.794 119.872 165.471 119.563C167.598 121.719 171.998 129.327 174.659 129.005C176.53 126.03 171.629 119.533 169.682 117.224C173.49 115.178 176.094 112.916 179.475 110.234Z"
fill="#DB4A70"
/>
<path
d="M191.224 50.1862C195.604 49.7344 205.231 49.7249 206.801 54.7082C207.531 57.0279 205.481 58.1657 203.569 57.8604C198.911 57.1174 194.506 55.7727 189.784 57.0792C179.249 59.5892 176.984 65.2807 171.833 73.6199C177.311 71.7867 187.154 66.9889 192.276 69.3149C195.251 72.6294 192.551 74.2657 189.344 75.3464C186.091 76.4417 182.651 76.8982 179.494 78.2819C176.086 79.7749 173.086 82.3412 169.486 83.2924C167.371 83.7814 165.813 82.2662 165.716 80.2562C164.948 64.2169 175.708 52.1952 191.224 50.1862Z"
fill="black"
/>
</g>
</svg>
</div>
);
}
// ─── Ghost hands ──────────────────────────────────────────────────────────────
// Rendered OUTSIDE the overflow-hidden zone as a sibling, positioned at the
// zone's current bottom edge. viewBox covers only Y=0..40 of the SVG space
// (hands were repositioned by the user to the top of the SVG at Y≈3).
// At 90px width: rendered height = 40 × (90/277) ≈ 13px.
function GhostHands() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="90"
viewBox="0 0 277 40"
fill="none"
className="pointer-events-none select-none"
>
<path
d="M24.8945 3.12207C26.0257 2.97044 27.7269 2.97852 28.6709 3.05078C35.3199 3.55943 41.0338 5.0199 46.3643 10.1807C48.8362 12.5741 50.338 15.2376 50.8438 18.043C51.3493 20.8476 50.8136 23.542 49.6396 25.9141C47.3381 30.5643 42.5391 34.1342 37.4121 35.8311C34.6883 36.7325 31.9918 36.9363 29.7305 37.2051L29.5977 37.2207L29.4639 37.2246C23.2385 37.4063 15.5677 36.4965 9.9668 32.0596V32.0586C6.87992 29.613 4.3051 26.5462 3.37109 22.9189C2.38985 19.1077 3.3542 15.2093 6.32422 11.6016C11.1202 5.77588 18.77 3.8176 24.8945 3.12109V3.12207Z"
fill="#FAF9F9"
stroke="black"
strokeWidth="6"
/>
<path
d="M247.41 3.04102C251.599 2.8133 257.302 3.52249 262.267 5.54395C267.159 7.53581 272.099 11.149 273.199 17.1689C273.807 20.4952 273.418 23.4731 272.188 26.0547C270.973 28.6043 269.034 30.567 266.853 32.0674C262.56 35.0198 256.931 36.4522 252.344 37.126L252.203 37.1465L252.062 37.1543C248.979 37.3123 245.275 37.008 241.741 36.1641C238.273 35.3358 234.682 33.9182 232.023 31.6475V31.6484C232.012 31.6392 232.001 31.6294 231.99 31.6201C231.983 31.6141 231.976 31.6086 231.969 31.6025V31.6016C229.383 29.4607 226.104 25.606 226.006 21.0195L226.003 20.4248C226.077 14.3441 229.349 10.1119 233.528 7.42188C237.725 4.72079 242.943 3.46814 247.286 3.05078L247.348 3.04395L247.41 3.04102Z"
fill="#FAF9F9"
stroke="black"
strokeWidth="6"
/>
</svg>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export function SnapchatLoader({
maxPullHeight = 120,
loadingHeight = 6,
loadingDuration = 4000,
resistance = 0.45,
zoneClassName = 'bg-muted',
onDrag,
onRelease,
onLoadingComplete,
onPhaseChange,
className,
children,
}: SnapchatLoaderProps) {
const [phase, setPhase] = React.useState<SnapchatLoaderPhase>('idle');
const [pullHeight, setPullHeight] = React.useState(0);
const [isCollapsing, setIsCollapsing] = React.useState(false);
const phaseRef = React.useRef<SnapchatLoaderPhase>('idle');
const startYRef = React.useRef<number | null>(null);
const currentHeightRef = React.useRef(0);
const loadingTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const collapseTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const changePhase = React.useCallback(
(next: SnapchatLoaderPhase) => {
phaseRef.current = next;
setPhase(next);
onPhaseChange?.(next);
},
[onPhaseChange],
);
// ── pointer events ─────────────────────────────────────────────────────────
const handlePointerDown = React.useCallback(
(e: React.PointerEvent) => {
if (phaseRef.current === 'loading') return;
startYRef.current = e.clientY;
changePhase('idle');
setPullHeight(0);
currentHeightRef.current = 0;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
},
[changePhase],
);
const handlePointerMove = React.useCallback(
(e: React.PointerEvent) => {
if (startYRef.current === null || phaseRef.current === 'loading') return;
const delta = e.clientY - startYRef.current;
if (delta <= 0) return;
const raw = delta * resistance;
const clamped = Math.min(raw, maxPullHeight);
currentHeightRef.current = clamped;
setPullHeight(clamped);
const next: SnapchatLoaderPhase = clamped >= maxPullHeight - 2 ? 'ready' : 'dragging';
if (next !== phaseRef.current) changePhase(next);
onDrag?.(clamped);
},
[maxPullHeight, resistance, onDrag, changePhase],
);
const handlePointerUp = React.useCallback(
(e: React.PointerEvent) => {
if (startYRef.current === null) return;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
const height = currentHeightRef.current;
startYRef.current = null;
if (phaseRef.current !== 'ready' && phaseRef.current !== 'dragging') return;
if (phaseRef.current === 'ready') {
onRelease?.(height);
changePhase('loading');
setPullHeight(loadingHeight);
loadingTimerRef.current = setTimeout(() => {
changePhase('complete');
setPullHeight(0);
onLoadingComplete?.();
setTimeout(() => changePhase('idle'), 400);
}, loadingDuration);
} else {
setIsCollapsing(true);
setPullHeight(0);
collapseTimerRef.current = setTimeout(() => {
setIsCollapsing(false);
changePhase('idle');
}, 450);
}
},
[changePhase, loadingHeight, loadingDuration, onRelease, onLoadingComplete],
);
// Prevent the browser's native pull-to-refresh on mobile.
// overscroll-behavior:none on the container handles most cases;
// setting it on document.body covers Chrome Android which checks the body.
React.useEffect(() => {
const prev = document.body.style.overscrollBehavior;
document.body.style.overscrollBehavior = 'none';
return () => {
document.body.style.overscrollBehavior = prev;
};
}, []);
React.useEffect(() => {
return () => {
if (loadingTimerRef.current) clearTimeout(loadingTimerRef.current);
if (collapseTimerRef.current) clearTimeout(collapseTimerRef.current);
};
}, []);
// ── derived values ─────────────────────────────────────────────────────────
const zoneHeight = phase === 'loading' ? loadingHeight : phase === 'complete' ? 0 : pullHeight;
const isActive = phase !== 'idle' && phase !== 'complete';
const isAnimated = phase === 'loading' || phase === 'complete' || isCollapsing;
const showGhost = isActive && phase !== 'loading';
// Hands SVG: viewBox 0 0 277 40, rendered at 90px wide → height ≈ 13px.
// We offset by half the SVG height so the visual centre of the hands
// sits exactly on the zone's bottom edge.
const HANDS_HALF_H = 6.5; // 13px / 2
const springTransition = 'transition-[height,transform,top] duration-[380ms] ease-[cubic-bezier(0.34,1.56,0.64,1)]';
return (
<div
ref={containerRef}
className={[
'sc-root relative w-full h-svh overflow-hidden select-none overscroll-none',
phase === 'dragging' || phase === 'ready' ? 'cursor-grabbing' : 'cursor-default',
className ?? '',
].join(' ')}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={{ touchAction: 'none' }}
>
{/* ── Pull zone (overflow-hidden) — reveals ghost body bottom-first ── */}
<div
className={[
'absolute top-0 left-0 right-0 z-20 overflow-hidden',
phase === 'loading' ? 'sc-shimmer-zone' : '',
isAnimated ? springTransition : '',
zoneClassName,
].join(' ')}
style={{ height: `${zoneHeight}px` }}
aria-hidden
>
{showGhost && (
<div className="absolute top-0 left-0 right-0" style={{ height: `${maxPullHeight}px` }}>
<GhostBody phase={phase} />
</div>
)}
</div>
{/* ── Hands — sibling of zone, always pinned to zone's bottom edge ── */}
{showGhost && (
<div
className={[
'absolute left-1/2 -translate-x-1/2 z-30',
isAnimated ? springTransition : '',
].join(' ')}
style={{ top: `${zoneHeight - HANDS_HALF_H}px` }}
aria-hidden
>
<GhostHands />
</div>
)}
{/* ── Page content pushed down ── */}
<div
className={[
'absolute inset-0 overflow-y-auto overflow-x-hidden',
isAnimated ? springTransition : '',
].join(' ')}
style={{ transform: `translateY(${zoneHeight}px)` }}
>
{children}
</div>
</div>
);
}