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