Components
A soft, feathered image reveal for React. Instead of a fade or a hard wipe, a tall cloudy alpha mask slides across the element via animated mask-position, so content bleeds in through a ghostly, feathered edge — like the image is forming out of fog. Zero dependencies (React only). Scroll-triggered once via IntersectionObserver by default, or drive it yourself with a controlled play prop. Reveals from any direction (up, down, left, right) using vertical and horizontal feather masks with no element rotation (so nothing clips), tunable softness (scale/mask-size), duration and CSS easing, an onHidden callback for swapping the child exactly when nothing is visible, and a graceful opacity fallback under prefers-reduced-motion.
npx @21st-dev/cli@beta add arlanoska/ghosty-revealLoading preview...
"use client"
import { useEffect, useRef, useState } from "react"
import { GhostReveal, type GhostDirection } from "@/components/ui/ghosty-reveal"
const MASK = "https://www.arlan.me/vault/ghost-mask.png"
const MASK_H = "https://www.arlan.me/vault/ghost-mask-h.png"
const IMAGES = Array.from({ length: 8 }, (_, i) => `https://www.arlan.me/vault/grid/grid-${i + 1}.webp`)
type Option = { value: string; label: string }
const DIRECTIONS: Option[] = [
{ value: "up", label: "Up" },
{ value: "down", label: "Down" },
{ value: "left", label: "Left" },
{ value: "right", label: "Right" },
]
// Exact list + values from the original arlan.me playground.
const EASINGS: Option[] = [
{ value: "cubic-bezier(0.16, 1, 0.3, 1)", label: "Glide" },
{ value: "cubic-bezier(0.22, 1, 0.36, 1)", label: "Soft out" },
{ value: "ease-in-out", label: "Ease in-out" },
{ value: "linear", label: "Linear" },
]
const STYLE = `
.grp {
--page: #f4f4f5; --surface: #ffffff; --line: rgba(0,0,0,0.09);
--ring: rgba(0,0,0,0.22); --t1: #1b1b1b; --t2: #6b6b6b; --sel: #fcfcfc;
display: flex; min-height: 100vh; width: 100%; align-items: center; justify-content: center;
background: var(--page); color: var(--t1); padding: 40px 24px;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, "Segoe UI", system-ui, sans-serif;
}
.grp-wrap { width: 100%; max-width: 720px; }
.grp-preview {
border: 1px solid var(--line); border-radius: 16px 16px 0 0; background: var(--surface);
padding: 40px; display: flex; align-items: center; justify-content: center; min-height: 380px;
}
.grp-card { width: min(460px, 100%); aspect-ratio: 4 / 3; border-radius: 10px; overflow: hidden;
box-shadow: 0 10px 30px rgba(0,0,0,0.12); background: var(--surface); }
.grp-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
.grp-panel { border: 1px solid var(--line); border-top: 0; border-radius: 0 0 16px 16px;
background: var(--surface); padding: 20px 20px 24px; display: flex; flex-direction: column; gap: 16px; }
.grp-row { display: flex; align-items: center; gap: 16px; }
.grp-row > .grp-label { color: var(--t2); font-size: 13px; flex: 0 0 auto; width: 84px; }
/* custom dropdown (matches the original combobox) */
.grp-dd { position: relative; margin-left: auto; }
.grp-dd-btn {
display: flex; align-items: center; justify-content: space-between; gap: 6px;
height: 34px; min-width: 118px; padding: 0 10px 0 12px;
border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--t1);
font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; outline: none;
transition: border-color .15s ease;
}
.grp-dd-btn:hover { border-color: var(--ring); }
.grp-dd-btn svg { color: var(--t2); transition: transform .18s ease; }
.grp-dd-btn[data-open="true"] svg { transform: rotate(180deg); }
.grp-dd-menu {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 20; min-width: 168px;
background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 4px;
box-shadow: 0 1px 2px rgba(17,24,39,0.06), 0 8px 24px rgba(17,24,39,0.08);
display: flex; flex-direction: column; gap: 2px;
}
.grp-dd-opt {
display: flex; align-items: center; width: 100%; text-align: left; padding: 8px 10px;
border: 0; border-radius: 6px; background: transparent; color: var(--t2);
font: inherit; font-size: 13px; cursor: pointer; transition: background .12s ease, color .12s ease;
}
.grp-dd-opt:hover { background: rgba(0,0,0,0.04); color: var(--t1); }
.grp-dd-opt[data-selected="true"] { background: var(--sel); color: var(--t1); font-weight: 500; }
.grp-slider { position: relative; margin-left: auto; display: flex; align-items: center; gap: 12px; flex: 1; }
.grp-slider .track { position: relative; flex: 1; height: 34px; border: 1px solid var(--line);
border-radius: 9px; background: var(--surface); display: flex; align-items: center; }
.grp-slider input[type=range] { -webkit-appearance: none; appearance: none; width: 100%; height: 34px;
margin: 0; background: transparent; cursor: pointer; }
.grp-slider input[type=range]::-webkit-slider-runnable-track { height: 34px; background: transparent; }
.grp-slider input[type=range]::-moz-range-track { height: 34px; background: transparent; }
.grp-slider input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none;
width: 3px; height: 16px; border-radius: 2px; background: var(--t1); opacity: 0.5; }
.grp-slider input[type=range]::-moz-range-thumb { width: 3px; height: 16px; border: 0; border-radius: 2px;
background: var(--t1); opacity: 0.5; }
.grp-slider .val { position: absolute; right: 12px; font-size: 13px; color: var(--t2);
font-variant-numeric: tabular-nums; pointer-events: none; }
`
function Chevron() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
)
}
function Dropdown({
value, options, onChange, ariaLabel,
}: {
value: string; options: Option[]; onChange: (v: string) => void; ariaLabel: string
}) {
const [open, setOpen] = useState(false)
const root = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => {
if (root.current && !root.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener("mousedown", onDown)
return () => document.removeEventListener("mousedown", onDown)
}, [open])
const current = options.find((o) => o.value === value)
return (
<div className="grp-dd" ref={root}>
<button
type="button"
className="grp-dd-btn"
data-open={open ? "true" : "false"}
aria-label={ariaLabel}
aria-expanded={open}
role="combobox"
onClick={() => setOpen((o) => !o)}
>
<span>{current?.label}</span>
<Chevron />
</button>
{open && (
<div className="grp-dd-menu" role="listbox">
{options.map((o) => (
<button
key={o.value}
type="button"
className="grp-dd-opt"
role="option"
aria-selected={o.value === value}
data-selected={o.value === value ? "true" : "false"}
onClick={() => {
onChange(o.value)
setOpen(false)
}}
>
{o.label}
</button>
))}
</div>
)}
</div>
)
}
export default function Default() {
const [direction, setDirection] = useState<GhostDirection>("up")
const [easing, setEasing] = useState(EASINGS[0].value)
const [durationMs, setDurationMs] = useState(1500)
const [idx, setIdx] = useState(0)
const [play, setPlay] = useState(false)
const durRef = useRef(durationMs)
durRef.current = durationMs
// For up/left the mask reveals at play=true; for down/right the ramp is
// inverted, so the revealed state is play=false. Normalize so the image is
// ALWAYS swapped while it is masked out — no flash — in every direction.
const revealPlay = direction === "up" || direction === "left"
useEffect(() => {
let alive = true
let t: ReturnType<typeof setTimeout>
const hidden = !revealPlay
setPlay(hidden)
const step = () => {
if (!alive) return
setPlay(revealPlay) // reveal the current photo
t = setTimeout(() => {
if (!alive) return
setPlay(hidden) // dissolve it back out
t = setTimeout(() => {
if (!alive) return
setIdx((i) => (i + 1) % IMAGES.length) // swap while hidden
step()
}, durRef.current + 160)
}, durRef.current + 1000)
}
t = setTimeout(step, 260)
return () => {
alive = false
clearTimeout(t)
}
}, [revealPlay])
const revealed = play === revealPlay
return (
<>
<style>{STYLE}</style>
<div className="grp">
<span data-ghost-open={revealed ? "true" : "false"} style={{ display: "none" }} />
<div className="grp-wrap">
<div className="grp-preview">
<GhostReveal
play={play}
direction={direction}
maskSrc={MASK}
maskSrcH={MASK_H}
duration={durationMs}
easing={easing}
scale={500}
className="grp-card"
>
<img src={IMAGES[idx]} alt="Ghostly reveal" />
</GhostReveal>
</div>
<div className="grp-panel">
<div className="grp-row">
<span className="grp-label">Direction</span>
<Dropdown
ariaLabel="Reveal direction"
value={direction}
options={DIRECTIONS}
onChange={(v) => setDirection(v as GhostDirection)}
/>
</div>
<div className="grp-row">
<span className="grp-label">Easing</span>
<Dropdown
ariaLabel="Reveal easing"
value={easing}
options={EASINGS}
onChange={setEasing}
/>
</div>
<div className="grp-row">
<span className="grp-label">Duration</span>
<div className="grp-slider">
<div className="track">
<input
type="range"
min={400}
max={2600}
step={10}
value={durationMs}
aria-label="Duration"
onChange={(e) => setDurationMs(Number(e.target.value))}
/>
<span className="val">{(durationMs / 1000).toFixed(2)}s</span>
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}