feat(converter): add animations to currency conversion and selection components

This commit is contained in:
2026-03-12 18:46:39 +01:00
parent 2b99854c22
commit 8f6a242273
4 changed files with 186 additions and 23 deletions

View File

@@ -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">
@@ -505,7 +511,26 @@ export function ConverterCard({
onClick={handleSwap}
aria-label="Swap currencies"
>
<ArrowUpDown className="h-4 w-4" />
<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,28 +566,72 @@ export function ConverterCard({
</Button>
</div>
{fromAsset && toAsset ? (
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
<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}
type={fromAsset.type}
size="sm"
/>
{fromAsset.code}
</span>
<span>to</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background/60 px-2 py-1">
<CurrencyIcon
code={toAsset.code}
type={toAsset.type}
size="sm"
/>
{toAsset.code}
</span>
</div>
<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}
type={fromAsset.type}
size="sm"
/>
{fromAsset.code}
</span>
<span>to</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background/60 px-2 py-1">
<CurrencyIcon
code={toAsset.code}
type={toAsset.type}
size="sm"
/>
{toAsset.code}
</span>
</motion.div>
</AnimatePresence>
) : null}
<p className="mt-2 break-all text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
{convertedDisplay}
<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">
{formatAmount(value, asset)} {asset.code}
<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>
))}

View File

@@ -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),
@@ -103,7 +105,32 @@ export function CurrencySelect({
disabled={disabled}
className="h-auto w-full justify-between rounded-xl border-border/70 px-3 py-2.5"
>
<AssetLabel asset={selectedAsset} />
<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
View File

@@ -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",

View File

@@ -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",