feat(converter): add animations to currency conversion and selection components
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpDown,
|
||||
@@ -140,6 +141,7 @@ export function ConverterCard({
|
||||
onPairChange,
|
||||
multiConversionCodes,
|
||||
}: ConverterCardProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const { data, error, isLoading, refresh } = useMarketRates();
|
||||
|
||||
const [amountInput, setAmountInput] = useState("1");
|
||||
@@ -147,6 +149,7 @@ export function ConverterCard({
|
||||
const [toCode, setToCode] = useState(DEFAULT_TO);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [marketRange, setMarketRange] = useState<MarketChartRange>("24h");
|
||||
const [swapAnimationStep, setSwapAnimationStep] = useState(0);
|
||||
|
||||
const debouncedAmount = useDebouncedValue(amountInput, 120);
|
||||
|
||||
@@ -250,6 +253,7 @@ export function ConverterCard({
|
||||
}, [fromAsset, toAsset]);
|
||||
|
||||
const handleSwap = () => {
|
||||
setSwapAnimationStep((current) => current + 1);
|
||||
setFromCode(toCode);
|
||||
setToCode(fromCode);
|
||||
};
|
||||
@@ -364,6 +368,8 @@ export function ConverterCard({
|
||||
: "--";
|
||||
|
||||
const amountError = inputValidation.ok ? null : inputValidation.error;
|
||||
const pairTransitionKey =
|
||||
fromAsset && toAsset ? `${fromAsset.code}-${toAsset.code}` : "empty";
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden border-border/70 bg-card/90">
|
||||
@@ -504,8 +510,27 @@ export function ConverterCard({
|
||||
className="mx-auto mb-0.5 rounded-full border-border/70 bg-background/50"
|
||||
onClick={handleSwap}
|
||||
aria-label="Swap currencies"
|
||||
>
|
||||
<motion.span
|
||||
className="inline-flex"
|
||||
animate={
|
||||
shouldReduceMotion
|
||||
? { scale: 1 }
|
||||
: { rotate: swapAnimationStep * 180, scale: [1, 1.08, 1] }
|
||||
}
|
||||
transition={{
|
||||
rotate: {
|
||||
duration: shouldReduceMotion ? 0.01 : 0.28,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
},
|
||||
scale: {
|
||||
duration: shouldReduceMotion ? 0.01 : 0.22,
|
||||
times: [0, 0.35, 1],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
</motion.span>
|
||||
</Button>
|
||||
<CurrencySelect
|
||||
label="To"
|
||||
@@ -541,7 +566,27 @@ export function ConverterCard({
|
||||
</Button>
|
||||
</div>
|
||||
{fromAsset && toAsset ? (
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={pairTransitionKey}
|
||||
className="mt-2 flex items-center gap-2 text-xs text-muted-foreground"
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? false
|
||||
: { opacity: 0, y: 4, filter: "blur(2px)" }
|
||||
}
|
||||
animate={
|
||||
shouldReduceMotion
|
||||
? { opacity: 1 }
|
||||
: { opacity: 1, y: 0, filter: "blur(0px)" }
|
||||
}
|
||||
exit={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: -4, filter: "blur(2px)" }
|
||||
}
|
||||
transition={{ duration: shouldReduceMotion ? 0.01 : 0.16 }}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background/60 px-2 py-1">
|
||||
<CurrencyIcon
|
||||
code={fromAsset.code}
|
||||
@@ -559,10 +604,34 @@ export function ConverterCard({
|
||||
/>
|
||||
{toAsset.code}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
) : null}
|
||||
<p className="mt-2 break-all text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.span
|
||||
key={convertedDisplay}
|
||||
className="inline-block"
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? false
|
||||
: { opacity: 0, y: 6, filter: "blur(2px)" }
|
||||
}
|
||||
animate={
|
||||
shouldReduceMotion
|
||||
? { opacity: 1 }
|
||||
: { opacity: 1, y: 0, filter: "blur(0px)" }
|
||||
}
|
||||
exit={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: -6, filter: "blur(2px)" }
|
||||
}
|
||||
transition={{ duration: shouldReduceMotion ? 0.01 : 0.18 }}
|
||||
>
|
||||
{convertedDisplay}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</p>
|
||||
{fromAsset ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
@@ -609,7 +678,30 @@ export function ConverterCard({
|
||||
{asset.code}
|
||||
</div>
|
||||
<p className="mt-1 text-base font-medium text-foreground">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.span
|
||||
key={`${asset.code}-${formatAmount(value, asset)}`}
|
||||
className="inline-block"
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? false
|
||||
: { opacity: 0, y: 4, filter: "blur(1px)" }
|
||||
}
|
||||
animate={
|
||||
shouldReduceMotion
|
||||
? { opacity: 1 }
|
||||
: { opacity: 1, y: 0, filter: "blur(0px)" }
|
||||
}
|
||||
exit={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: -4, filter: "blur(1px)" }
|
||||
}
|
||||
transition={{ duration: shouldReduceMotion ? 0.01 : 0.15 }}
|
||||
>
|
||||
{formatAmount(value, asset)} {asset.code}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
|
||||
import { POPULAR_CODES } from "@/lib/assets";
|
||||
@@ -61,6 +62,7 @@ export function CurrencySelect({
|
||||
disabled
|
||||
}: CurrencySelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const selectedAsset = useMemo(
|
||||
() => assets.find((asset) => asset.code === value),
|
||||
@@ -102,8 +104,33 @@ export function CurrencySelect({
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className="h-auto w-full justify-between rounded-xl border-border/70 px-3 py-2.5"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 overflow-hidden">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={selectedAsset?.code ?? "empty"}
|
||||
className="block min-w-0 flex-1"
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? false
|
||||
: { opacity: 0, y: 4, filter: "blur(2px)" }
|
||||
}
|
||||
animate={
|
||||
shouldReduceMotion
|
||||
? { opacity: 1 }
|
||||
: { opacity: 1, y: 0, filter: "blur(0px)" }
|
||||
}
|
||||
exit={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: -4, filter: "blur(2px)" }
|
||||
}
|
||||
transition={{ duration: shouldReduceMotion ? 0.01 : 0.18 }}
|
||||
>
|
||||
<AssetLabel asset={selectedAsset} />
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"cmdk": "^1.0.4",
|
||||
"cryptocurrency-icons": "^0.18.1",
|
||||
"currency-flags": "github:vivekimsit/currency-flags",
|
||||
"framer-motion": "^12.36.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
"next": "^14.2.24",
|
||||
"react": "^18.2.0",
|
||||
@@ -3502,6 +3503,33 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.36.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.36.0.tgz",
|
||||
"integrity": "sha512-4PqYHAT7gev0ke0wos+PyrcFxI0HScjm3asgU8nSYa8YzJFuwgIvdj3/s3ZaxLq0bUSboIn19A2WS/MHwLCvfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.36.0",
|
||||
"motion-utils": "^12.36.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
@@ -4688,6 +4716,21 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.36.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.36.0.tgz",
|
||||
"integrity": "sha512-Ep1pq8P88rGJ75om8lTCA13zqd7ywPGwCqwuWwin6BKc0hMLkVfcS6qKlRqEo2+t0DwoUcgGJfXwaiFn4AOcQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.36.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.36.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
|
||||
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"cmdk": "^1.0.4",
|
||||
"cryptocurrency-icons": "^0.18.1",
|
||||
"currency-flags": "github:vivekimsit/currency-flags",
|
||||
"framer-motion": "^12.36.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
"next": "^14.2.24",
|
||||
"react": "^18.2.0",
|
||||
|
||||
Reference in New Issue
Block a user