Front
Click me to see the back
Back
Click me to see the front
1import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/pxl/card";2import { Flip, FlipBack, FlipFront, FlipTrigger } from "@/components/ui/pxl/flip";3import { WidgetArea } from "@/components/ui/pxl/widget-area";4
5export default function FlipPreview() {6 return (7 <WidgetArea size="md">8 <Flip9 className="size-full"10 flipDirection="horizontal"11 >12 <FlipTrigger>13 <FlipFront className="w-full">14 <Card size="lg" className="size-full" variant="default">15 <CardHeader>16 <CardTitle>17 <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><path d="M18 22H6V20H18V22ZM6 20H4V18H6V20ZM20 20H18V18H20V20ZM4 18H2V6H4V18ZM22 18H20V6H22V18ZM13 17H11V11H13V17ZM13 9H11V7H13V9ZM6 6H4V4H6V6ZM20 6H18V4H20V6ZM18 4H6V2H18V4Z"></path></svg>18 Front19 </CardTitle>20 </CardHeader>21 <CardContent className="flex items-center justify-center">22 Click me to see the back23 </CardContent>24 </Card>25 </FlipFront>26 <FlipBack className="w-full">27 <Card size="lg" className="size-full" variant="danger">28 <CardHeader>29 <CardTitle>30 <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><path d="M4 13h8v6h2v2h-2v2h-2v-8H2v-4h2v2Zm12 6h-2v-2h2v2Zm2-2h-2v-2h2v2Zm2-2h-2v-2h2v2Zm-6-6h8v4h-2v-2h-8V5h-2V3h2V1h2v8Zm-8 2H4V9h2v2Zm2-2H6V7h2v2Zm2-2H8V5h2v2Z"></path></svg>31 Back32 </CardTitle>33 </CardHeader>34 <CardContent className="flex items-center justify-center">35 Click me to see the front36 </CardContent>37 </Card>38 </FlipBack>39 </FlipTrigger>40 </Flip>41 </WidgetArea>42 );43}Installation
Section titled “Installation”pnpm dlx shadcn@latest add pxl-ui/registry/flip
Install the following dependencies:
pnpm add motion
Copy and paste the following code into your project.
1"use client";2
3import {4 animate,5 motion,6 type PanInfo,7 useMotionValue,8 useSpring,9} from "motion/react";10import {11 createContext,12 forwardRef,13 type HTMLAttributes,14 useCallback,15 useContext,16 useEffect,17 useImperativeHandle,18 useMemo,19 useRef,20 useState,21} from "react";22
23import { cn } from "@/lib/utils";24
25/**26 * A handle for programmatically controlling the Flip.27 */28export type FlipRef = {29 /**30 * Programmatically flips the card to a specific state or toggles it.31 * @param flipped The target state. If undefined, the card will toggle.32 */33 flip: (flipped?: boolean) => void;34 /**35 * Toggles the current flipped state of the card.36 */37 toggle: () => void;38};39
40type FlipContextValue = {41 isFlipped: boolean;42 toggle: () => void;43 setFlipped: (flipped: boolean) => void;44 flipDirection: "horizontal" | "vertical";45 manualFlip: boolean;46 duration: number;47 easing: string;48 parallaxEnabled: boolean;49};50
51const FlipContext = createContext<FlipContextValue | null>(null);52
53const useFlip = () => {54 const context = useContext(FlipContext);55 if (!context) {56 throw new Error("useFlip must be used within a <Flip> component.");57 }58 return context;59};60
61type Props = HTMLAttributes<HTMLDivElement> & {62 /** The direction the card should flip. */63 flipDirection?: "horizontal" | "vertical";64 /** The duration of the CSS flip animation in milliseconds. */65 duration?: number;66 /** The CSS timing function for the CSS flip animation. */67 easing?: string;68 /** Enables a 3D parallax tilt effect on mouse move. */69 parallaxEnabled?: boolean;70 /** Controls the intensity of the parallax effect. Higher numbers mean more tilt. */71 parallaxIntensity?: number;72 /** Enables manual, gesture-based flipping instead of CSS-based interactions. */73 manualFlip?: boolean;74 /** A controlled state for whether the card is flipped. */75 isFlipped?: boolean;76 /** Callback function when the flip state changes, for use with controlled state. */77 onFlip?: (isFlipped: boolean) => void;78};79
80const Flip = forwardRef<FlipRef, Props>(81 (82 {83 flipDirection = "horizontal",84 duration = 600,85 easing = "ease-in-out",86 parallaxEnabled = false,87 parallaxIntensity = 10,88 manualFlip = false,89 isFlipped: isFlippedProp,90 onFlip,91 className,92 style,93 children,94 onMouseMove,95 onMouseLeave,96 ...props97 },98 ref,99 ) => {100 const isControlled = isFlippedProp !== undefined;101 const [internalFlipped, setInternalFlipped] = useState(false);102 const isFlipped = isControlled ? !!isFlippedProp : internalFlipped;103
104 const setFlipped = useCallback(105 (next: boolean) => {106 if (!isControlled) setInternalFlipped(next);107 onFlip?.(next);108 },109 [isControlled, onFlip],110 );111
112 const toggle = useCallback(() => {113 setFlipped(!isFlipped);114 }, [isFlipped, setFlipped]);115
116 const flip = useCallback(117 (flipped?: boolean) => {118 if (flipped === undefined) {119 toggle();120 return;121 }122 setFlipped(flipped);123 },124 [toggle, setFlipped],125 );126
127 useImperativeHandle(ref, () => ({ flip, toggle }), [flip, toggle]);128
129 // --- Parallax tilt ---------------------------------------------------130 const rotateX = useMotionValue(0);131 const rotateY = useMotionValue(0);132 const springRotateX = useSpring(rotateX, { stiffness: 300, damping: 30 });133 const springRotateY = useSpring(rotateY, { stiffness: 300, damping: 30 });134
135 function handleMouseMove(event: React.MouseEvent<HTMLDivElement>) {136 onMouseMove?.(event);137 if (!parallaxEnabled) return;138 const rect = event.currentTarget.getBoundingClientRect();139 const px = (event.clientX - rect.left) / rect.width - 0.5;140 const py = (event.clientY - rect.top) / rect.height - 0.5;141 rotateY.set(px * parallaxIntensity * 2);142 rotateX.set(-py * parallaxIntensity * 2);143 }144
145 function handleMouseLeave(event: React.MouseEvent<HTMLDivElement>) {146 onMouseLeave?.(event);147 if (!parallaxEnabled) return;148 rotateX.set(0);149 rotateY.set(0);150 }151
152 const contextValue = useMemo<FlipContextValue>(153 () => ({154 isFlipped,155 toggle,156 setFlipped,157 flipDirection,158 manualFlip,159 duration,160 easing,161 parallaxEnabled,162 }),163 [164 isFlipped,165 toggle,166 setFlipped,167 flipDirection,168 manualFlip,169 duration,170 easing,171 parallaxEnabled,172 ],173 );174
175 return (176 <FlipContext.Provider value={contextValue}>177 <div178 {...props}179 data-flipped={isFlipped}180 data-flip-direction={flipDirection}181 className={cn("relative", className)}182 style={{ perspective: 1200, ...style }}183 onMouseMove={handleMouseMove}184 onMouseLeave={handleMouseLeave}185 >186 <motion.div187 className="relative h-full w-full"188 style={{189 transformStyle: "preserve-3d",190 rotateX: parallaxEnabled ? springRotateX : 0,191 rotateY: parallaxEnabled ? springRotateY : 0,192 }}193 >194 {children}195 </motion.div>196 </div>197 </FlipContext.Provider>198 );199 },200);201Flip.displayName = "Flip";202
203const VELOCITY_THRESHOLD = 500;204const ROTATE_MIDPOINT = 90;205const ROTATE_MIN = -30;206const ROTATE_MAX = 210;207
208const FlipTrigger = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(209 ({ className, style, children, onClick, onKeyDown, ...props }, ref) => {210 const {211 isFlipped,212 toggle,213 setFlipped,214 flipDirection,215 manualFlip,216 duration,217 easing,218 } = useFlip();219
220 const target = isFlipped ? 180 : 0;221 const rotate = useMotionValue(target);222 const isDragging = useRef(false);223 const dragStartRotate = useRef(target);224 const containerRef = useRef<HTMLDivElement | null>(null);225
226 useImperativeHandle(ref, () => containerRef.current as HTMLDivElement);227
228 // Keep the motion value in sync with isFlipped for the manual/gesture mode.229 // biome-ignore lint/correctness/useExhaustiveDependencies: target230 useEffect(() => {231 if (!manualFlip || isDragging.current) return;232 const controls = animate(rotate, target, {233 type: "spring",234 stiffness: 300,235 damping: 32,236 });237 return () => controls.stop();238 }, [manualFlip, target]);239
240 function handleClick(event: React.MouseEvent<HTMLDivElement>) {241 onClick?.(event);242 if (manualFlip) return;243 toggle();244 }245
246 function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {247 onKeyDown?.(event);248 if (event.key === "Enter" || event.key === " ") {249 event.preventDefault();250 toggle();251 }252 }253
254 function handleDragStart() {255 isDragging.current = true;256 dragStartRotate.current = rotate.get();257 }258
259 function handleDrag(260 _event: MouseEvent | TouchEvent | PointerEvent,261 info: PanInfo,262 ) {263 const el = containerRef.current;264 if (!el) return;265 const size =266 flipDirection === "horizontal" ? el.offsetWidth : el.offsetHeight;267 const delta =268 flipDirection === "horizontal" ? info.offset.x : info.offset.y;269 const deltaRotate = size > 0 ? (delta / size) * 180 : 0;270 const next = Math.min(271 ROTATE_MAX,272 Math.max(ROTATE_MIN, dragStartRotate.current + deltaRotate),273 );274 rotate.set(next);275 }276
277 function handleDragEnd(278 _event: MouseEvent | TouchEvent | PointerEvent,279 info: PanInfo,280 ) {281 isDragging.current = false;282 const velocity =283 flipDirection === "horizontal" ? info.velocity.x : info.velocity.y;284 const current = rotate.get();285
286 let nextFlipped: boolean;287 if (Math.abs(velocity) > VELOCITY_THRESHOLD) {288 nextFlipped = velocity > 0;289 } else {290 nextFlipped = current > ROTATE_MIDPOINT;291 }292
293 animate(rotate, nextFlipped ? 180 : 0, {294 type: "spring",295 stiffness: 300,296 damping: 32,297 });298
299 if (nextFlipped !== isFlipped) {300 setFlipped(nextFlipped);301 }302 }303
304 const rotateStyle =305 flipDirection === "horizontal"306 ? { rotateY: rotate }307 : { rotateX: rotate };308
309 const dragAxis = flipDirection === "horizontal" ? "x" : "y";310
311 return (312 // biome-ignore lint/a11y/useSemanticElements: trigger313 <div314 {...props}315 ref={containerRef}316 role="button"317 tabIndex={0}318 aria-pressed={isFlipped}319 onClick={handleClick}320 onKeyDown={handleKeyDown}321 className={cn(322 "relative h-full w-full select-none",323 manualFlip ? "cursor-grab active:cursor-grabbing" : "cursor-pointer",324 className,325 )}326 style={{ transformStyle: "preserve-3d", ...style }}327 >328 {manualFlip ? (329 <motion.div330 className="relative h-full w-full"331 style={{ transformStyle: "preserve-3d", ...rotateStyle }}332 drag={dragAxis}333 dragConstraints={{ left: 0, right: 0, top: 0, bottom: 0 }}334 dragElastic={0.2}335 dragMomentum={false}336 onDragStart={handleDragStart}337 onDrag={handleDrag}338 onDragEnd={handleDragEnd}339 >340 {children}341 </motion.div>342 ) : (343 <div344 className="relative h-full w-full"345 style={{346 transformStyle: "preserve-3d",347 transform:348 flipDirection === "horizontal"349 ? `rotateY(${target}deg)`350 : `rotateX(${target}deg)`,351 transition: `transform ${duration}ms ${easing}`,352 }}353 >354 {children}355 </div>356 )}357 </div>358 );359 },360);361FlipTrigger.displayName = "FlipTrigger";362
363const FlipFront = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(364 ({ className, style, ...props }, ref) => {365 return (366 <div367 {...props}368 ref={ref}369 className={cn("absolute inset-0", className)}370 style={{371 backfaceVisibility: "hidden",372 WebkitBackfaceVisibility: "hidden",373 ...style,374 }}375 />376 );377 },378);379FlipFront.displayName = "FlipFront";380
381const FlipBack = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(382 ({ className, style, ...props }, ref) => {383 const { flipDirection } = useFlip();384 return (385 <div386 {...props}387 ref={ref}388 className={cn("absolute inset-0", className)}389 style={{390 backfaceVisibility: "hidden",391 WebkitBackfaceVisibility: "hidden",392 transform:393 flipDirection === "horizontal"394 ? "rotateY(180deg)"395 : "rotateX(180deg)",396 ...style,397 }}398 />399 );400 },401);402FlipBack.displayName = "FlipBack";403
404export type { FlipContextValue, Props };405export { Flip, FlipBack, FlipContext, FlipFront, FlipTrigger, useFlip };Update the import paths to match your project setup.
import { Flip, FlipBack, FlipFront, FlipTrigger,} from "@/components/ui/pxl/flip";<Flip className="size-full" flipDirection="horizontal"> <FlipTrigger> <FlipFront className="w-full"> Front </FlipFront> <FlipBack className="w-full"> Back </FlipBack> </FlipTrigger></Flip>Composition
Section titled “Composition”Use the following composition to build a Flip:
Flip└── FlipTrigger ├── FlipFront └── FlipBackManual Trigger
Section titled “Manual Trigger”Use isFlipped to change the state from outside.
Front
Back
1import { useState } from "react";2
3import { Button } from "@/components/ui/pxl/button";4import {5 Card,6 CardContent,7 CardHeader,8 CardTitle,9} from "@/components/ui/pxl/card";10import {11 Flip,12 FlipBack,13 FlipFront,14 FlipTrigger,15} from "@/components/ui/pxl/flip";16import { WidgetArea } from "@/components/ui/pxl/widget-area";17
18export default function FlipPreview() {19 const [isFlipped, setFlipped] = useState(false);20
21 return (22 <div className="w-full flex flex-col gap-2.5 items-center justify-center">23 <Button onClick={() => setFlipped((v) => !v)}>Flip</Button>24
25 <WidgetArea size="md">26 <Flip27 className="size-full"28 flipDirection="vertical"29 isFlipped={isFlipped}30 >31 <FlipTrigger>32 <FlipFront className="w-full">33 <Card size="lg" className="size-full" variant="default">34 <CardHeader>35 <CardTitle>36 <svg37 xmlns="http://www.w3.org/2000/svg"38 fill="currentColor"39 viewBox="0 0 24 24"40 >41 <path d="M18 22H6V20H18V22ZM6 20H4V18H6V20ZM20 20H18V18H20V20ZM4 18H2V6H4V18ZM22 18H20V6H22V18ZM13 17H11V11H13V17ZM13 9H11V7H13V9ZM6 6H4V4H6V6ZM20 6H18V4H20V6ZM18 4H6V2H18V4Z"></path>42 </svg>43 Front44 </CardTitle>45 </CardHeader>46 <CardContent></CardContent>47 </Card>48 </FlipFront>49 <FlipBack className="w-full">50 <Card size="lg" className="size-full" variant="danger">51 <CardHeader>52 <CardTitle>53 <svg54 xmlns="http://www.w3.org/2000/svg"55 fill="currentColor"56 viewBox="0 0 24 24"57 >58 <path d="M4 13h8v6h2v2h-2v2h-2v-8H2v-4h2v2Zm12 6h-2v-2h2v2Zm2-2h-2v-2h2v2Zm2-2h-2v-2h2v2Zm-6-6h8v4h-2v-2h-8V5h-2V3h2V1h2v8Zm-8 2H4V9h2v2Zm2-2H6V7h2v2Zm2-2H8V5h2v2Z"></path>59 </svg>60 Back61 </CardTitle>62 </CardHeader>63 <CardContent></CardContent>64 </Card>65 </FlipBack>66 </FlipTrigger>67 </Flip>68 </WidgetArea>69 </div>70 );71}