1import { useState } from "react";2
3import { Button } from "@/components/ui/pxl/button";4import {5 Card,6 CardContent,7 CardDescription,8 CardHeader,9 CardTitle,10} from "@/components/ui/pxl/card";11import { Reveal } from "@/components/ui/pxl/reveal";12import { WheelList } from "@/components/ui/pxl/wheel-list";13import { WidgetArea } from "@/components/ui/pxl/widget-area";14
15export default function RevealPreview() {16 const [open, setOpen] = useState(false);17
18 return (19 <div className="flex flex-col gap-4">20 <Button onClick={() => setOpen((v) => !v)}>21 {open ? "Hide menu" : "Show menu"}22 </Button>23
24 <div className="w-widget-md aspect-square">25 <Reveal26 show={open}27 variant="muted"28 duration={1}29 >30 <WidgetArea size="sm">31 <Card size="lg" className="size-full">32 <CardHeader>33 <CardTitle>Hello World!</CardTitle>34 <CardDescription>Hello</CardDescription>35 </CardHeader>36 <CardContent>37 <WheelList38 align="start"39 visibleCount={3}40 defaultValue="ITEM"41 onChange={(evt) =>42 console.log(43 evt.settled ? `selected ${evt.value}` : "coasting",44 )45 }46 onSelect={(value) => console.log(value)}47 >48 {[49 "POKÉDEX",50 "POKÉMON",51 "ITEM",52 "TRAINER",53 "SAVE",54 "OPTION",55 "EXIT",56 ].map((i) => (57 <WheelList.Option key={i} value={i}>58 {i}59 </WheelList.Option>60 ))}61 </WheelList>62 </CardContent>63 </Card>64 </WidgetArea>65 </Reveal>66 </div>67 </div>68 );69}Installation
Section titled “Installation”pnpm dlx shadcn@latest add pxl-ui/registry/reveal
Install the following dependencies:
pnpm add motion
Copy and paste the following code into your project.
1"use client";2
3import { cva, type VariantProps } from "class-variance-authority";4import {5 AnimatePresence,6 motion,7 type Transition,8 useReducedMotion,9} from "motion/react";10import {11 type PropsWithChildren,12 useEffect,13 useMemo,14 useRef,15 useState,16} from "react";17
18import { cn } from "@/lib/utils";19
20const revealVariants = cva("block", {21 variants: {22 pattern: {23 default: "",24 diagonal: "",25 radial: "",26 random: ""27 },28 size: {29 default: "",30 sm: "",31 md: "",32 lg: ""33 },34 variant: {35 default: "bg-background",36 card: "bg-card",37 popover: "bg-popover",38 muted: "bg-muted",39 primary: "bg-primary",40 success: "bg-success",41 danger: "bg-danger",42 warning: "bg-warning",43 info: "bg-info",44 foreground: "bg-foreground",45 primaryForeground: "bg-primary-foreground",46 successForeground: "bg-success-foreground",47 dangerForeground: "bg-danger-foreground",48 warningForeground: "bg-warning-foreground",49 infoForeground: "bg-info-foreground",50 },51 },52 defaultVariants: {53 pattern: "default",54 size: "default",55 variant: "default",56 },57});58
59 /**60 * Sweep order:61 * - "radial": from the center outwards (iris, RPG dialogue box look)62 * - "diagonal": from one corner to the other63 * - "random": cluttered, more "static"64 */65export type RevealPattern = NonNullable<VariantProps<typeof revealVariants>["pattern"]>;66
67/**68 * Calculate, for a grid of cols x rows, a delay per cell by combining69 * an ordered pattern (radial/diagonal) with a small random jitter.70 */71function useCellDelays(72 cols: number,73 rows: number,74 maxDelay: number,75 pattern: RevealPattern,76 jitter: number,77) {78 return useMemo(() => {79 const count = cols * rows;80 if (count === 0) return [];81
82 const centerCol = (cols - 1) / 2;83 const centerRow = (rows - 1) / 2;84 const maxDist =85 pattern === "radial"86 ? Math.hypot(87 Math.max(centerCol, cols - 1 - centerCol),88 Math.max(centerRow, rows - 1 - centerRow),89 ) || 190 : cols - 1 + (rows - 1) || 1;91
92 const values: number[] = [];93 for (let row = 0; row < rows; row++) {94 for (let col = 0; col < cols; col++) {95 const order =96 pattern === "radial"97 ? Math.hypot(col - centerCol, row - centerRow) / maxDist98 : (pattern === "diagonal" || pattern === "default")99 ? (col + row) / maxDist100 : Math.random();101 const noise = (Math.random() - 0.5) * jitter;102 values.push(Math.min(1, Math.max(0, order + noise)) * maxDelay);103 }104 }105 return values;106 // eslint-disable-next-line react-hooks/exhaustive-deps107 }, [cols, rows, maxDelay, pattern, jitter]);108}109
110/**111 * The sizes for the pixels112 */113const PIXEL_SIZES : Record<NonNullable<VariantProps<typeof revealVariants>["size"]>, number> = {114 "default": 2,115 "sm": 1,116 "md": 2,117 "lg": 4,118};119
120export function Reveal({121 show,122 children,123 className,124 contentClassName,125 duration = 0.55,126 jitter = 0.15,127 maxCells = 400,128 onExitComplete,129 pattern = "default",130 size = "default",131 variant = "default",132}: {133 /** Controls whether the content is visible or not */134 show: boolean;135 className?: string;136 contentClassName?: string;137 /** Total duration of the transition, in seconds */138 duration?: number;139 /** how much noise/jitter is mixed in on top of the ordered pattern (0 = perfect, 1 = chaotic) */140 jitter?: number;141 /** Maximum number of rendered blocks (for performance and to avoid a "salt & pepper" look) */142 maxCells?: number;143 onExitComplete?: () => void;144} & VariantProps<typeof revealVariants> &145 PropsWithChildren) {146 const containerRef = useRef<HTMLDivElement>(null);147 const [grid, setGrid] = useState({ cols: 0, rows: 0 });148 const reducedMotion = useReducedMotion();149
150 useEffect(() => {151 const el = containerRef.current;152 if (!el) return;153
154 const update = () => {155 const { width, height } = el.getBoundingClientRect();156 if (!width || !height) return;157
158 let cols = Math.max(1, Math.round(width / PIXEL_SIZES[size ?? "default"]));159 let rows = Math.max(1, Math.round(height / PIXEL_SIZES[size ?? "default"]));160
161 if (cols * rows > maxCells) {162 const scale = Math.sqrt((cols * rows) / maxCells);163 cols = Math.max(1, Math.round(cols / scale));164 rows = Math.max(1, Math.round(rows / scale));165 }166 setGrid((prev) =>167 prev.cols === cols && prev.rows === rows ? prev : { cols, rows },168 );169 };170
171 update();172 const ro = new ResizeObserver(update);173 ro.observe(el);174 return () => ro.disconnect();175 }, [size, maxCells]);176
177 const cellDuration = Math.max(0.12, duration * 0.5);178 const maxDelay = Math.max(0, duration - cellDuration);179 const delays = useCellDelays(grid.cols, grid.rows, maxDelay, pattern ?? "default", jitter);180
181 const contentTransition: Transition = reducedMotion182 ? { duration: 0.15, ease: "easeOut" }183 : { duration: duration * 0.8, ease: [0.16, 1, 0.3, 1] };184
185 const cellEase = [0.4, 0, 0.2, 1] as const;186
187 return (188 <div189 ref={containerRef}190 className={cn("relative isolate size-full", className)}191 >192 <AnimatePresence onExitComplete={onExitComplete}>193 {show && (194 <motion.div195 key="pixel-reveal-content"196 className={cn("relative", contentClassName)}197 initial={{ opacity: 0, scale: reducedMotion ? 1 : 0.985 }}198 animate={{ opacity: 1, scale: 1 }}199 exit={{ opacity: 0, scale: reducedMotion ? 1 : 0.985 }}200 transition={contentTransition}201 >202 {children}203
204 {!reducedMotion && delays.length > 0 && (205 <div206 aria-hidden207 className="pointer-events-none absolute inset-0 grid overflow-hidden"208 style={{209 gridTemplateColumns: `repeat(${grid.cols}, 1fr)`,210 gridTemplateRows: `repeat(${grid.rows}, 1fr)`,211 }}212 >213 {delays.map((delay, i) => (214 <motion.span215 key={i.toString()}216 className={revealVariants({217 variant,218 })}219 initial={{ opacity: 1, scale: 1 }}220 animate={{221 opacity: 0,222 scale: 0.6,223 transition: {224 duration: cellDuration,225 delay,226 ease: cellEase,227 },228 }}229 exit={{230 opacity: 1,231 scale: 1,232 // Reversed order: the last block to disappear upon opening is the first to reappear upon closing.233 transition: {234 duration: cellDuration,235 delay: maxDelay - delay,236 ease: cellEase,237 },238 }}239 />240 ))}241 </div>242 )}243 </motion.div>244 )}245 </AnimatePresence>246 </div>247 );248}Update the import paths to match your project setup.
import { Reveal } from "@/components/ui/pxl/reveal"<Reveal show={show} duration={seconds}>{content}</Reveal>Patterns
Section titled “Patterns”Use the pattern prop to change the pattern of the reveal.
1import { useState } from "react";2
3import { Button } from "@/components/ui/pxl/button";4import { Reveal, type RevealPattern } from "@/components/ui/pxl/reveal";5import { WidgetArea } from "@/components/ui/pxl/widget-area";6
7export default function RetroMenuExample() {8 const [open, setOpen] = useState(false);9
10 const [pattern, setPattern] = useState<RevealPattern>("diagonal");11
12 return (13 <div className="flex flex-col gap-4 items-center">14
15 <div className="flex flex-row gap-2 w-full">16
17 <Button18 className="flex-1"19 onClick={() => {20 setPattern("radial");21 setOpen((v) => !v);22 }}23 >24 {open ? "Hide radial" : "Show radial"}25 </Button>26
27 <Button28 className="flex-1"29 onClick={() => {30 setPattern("diagonal");31 setOpen((v) => !v);32 }}33 >34 {open ? "Hide diagonal" : "Show diagonal"}35 </Button>36
37 <Button38 className="flex-1"39 onClick={() => {40 setPattern("random");41 setOpen((v) => !v);42 }}43 >44 {open ? "Hide random" : "Show random"}45 </Button>46 </div>47
48 <div className="w-widget-md aspect-square">49 <Reveal50 show={open}51 pattern={pattern}52 variant="muted"53 >54 <WidgetArea size="sm">55 <nav className="">56 <ul className="space-y-2">57 <li className="cursor-pointer hover:text-yellow-300">58 ▸ Continue59 </li>60 <li className="cursor-pointer hover:text-yellow-300">61 ▸ Items62 </li>63 <li className="cursor-pointer hover:text-yellow-300">64 ▸ Equipment65 </li>66 <li className="cursor-pointer hover:text-yellow-300">67 ▸ Save68 </li>69 </ul>70 </nav>71 </WidgetArea>72 </Reveal>73 </div>74 </div>75 );76}Variants
Section titled “Variants”Use the variant prop to change the variant of the reveal.
1import { type ComponentProps, useState } from "react";2
3import { Button } from "@/components/ui/pxl/button";4import { Reveal } from "@/components/ui/pxl/reveal";5import { WidgetArea } from "@/components/ui/pxl/widget-area";6
7export default function RetroMenuExample() {8 const [open, setOpen] = useState(false);9
10 const [variant, setVariant] =11 useState<ComponentProps<typeof Reveal>["variant"]>("default");12
13 return (14 <div className="flex flex-col gap-4 items-center">15
16 <div className="flex flex-row gap-2 w-full">17 <Button18 className="flex-1"19 onClick={() => {20 setVariant("default");21 setOpen((v) => !v);22 }}23 >24 Default (Background)25 </Button>26
27 <Button28 variant="success"29 className="flex-1"30 onClick={() => {31 setVariant("success");32 setOpen((v) => !v);33 }}34 >35 Success36 </Button>37
38 <Button39 variant="danger"40 className="flex-1"41 onClick={() => {42 setVariant("dangerForeground");43 setOpen((v) => !v);44 }}45 >46 Danger Foreground47 </Button>48 </div>49
50 <div className="w-widget-md aspect-square">51 <Reveal show={open} variant={variant}>52 <WidgetArea size="sm">53 <nav className="">54 <ul className="space-y-2">55 <li className="cursor-pointer hover:text-yellow-300">56 ▸ Continue57 </li>58 <li className="cursor-pointer hover:text-yellow-300">59 ▸ Items60 </li>61 <li className="cursor-pointer hover:text-yellow-300">62 ▸ Equipment63 </li>64 <li className="cursor-pointer hover:text-yellow-300">▸ Save</li>65 </ul>66 </nav>67 </WidgetArea>68 </Reveal>69 </div>70 </div>71 );72}