Cómo crear un toggle de pricing con animación spring en React
Un toggle de pricing React con precios animados usa useSpring de Framer Motion para controlar un toggle con forma de pill, y AnimatePresence en modo popLayout para hacer el crossfade vertical del precio cuando cambia el periodo de facturación. Toda la interacción funciona con CSS custom properties, así que se adapta a cualquier token de tema.
- Stack: React 19 + Framer Motion 11 + lucide-react, ~130 líneas, cero dependencias adicionales.
- APIs clave: useSpring, useTransform, AnimatePresence (modo popLayout), motion.div whileHover/whileTap.
- El toggle incluye aria-pressed y aria-label para la accesibilidad con teclado y lectores de pantalla.
- Grid responsive con columnas auto-fit que se reduce a una sola columna en pantallas estrechas.
- Los colores se controlan por completo con CSS custom properties (--color-background, --color-accent, etc.), compatibles con los 7 presets de tema.
Esta sección de pricing combina un toggle de facturación con física spring y un precio que se transforma entre los valores mensual y anual mediante un crossfade vertical. En lugar de un cambio brusco del número, el precio antiguo sale hacia arriba mientras el nuevo entra desde abajo. Un badge de descuento aparece sobre el label anual, animado con un rebote de escala spring. Toda la sección es independiente del tema, controlada por completo con CSS custom properties.
Anatomía
La sección tiene tres zonas apiladas: un header centrado (eyebrow, h2, subtítulo) que aparece en fundido al hacer scroll; una fila de toggle de facturación con una pill SpringToggle y un badge de descuento bajo AnimatePresence; y un grid CSS de tarjetas de plan. Cada tarjeta contiene un nombre, una descripción, un bloque AnimatedPrice, un label de facturación, una lista de features con iconos Check y un botón CTA. El plan destacado se muestra con un esquema de colores invertido (fondo = foreground) y un ligero scale(1.02).
Cómo funciona
El componente SpringToggle crea un spring de Framer Motion (stiffness 500, damping 35) que mapea 0/1 a un offset x en píxeles para el thumb mediante useTransform. Llamar a spring.set(yearly ? 1 : 0) en cada render controla la posición del thumb sin necesidad de ningún useEffect. El bloque AnimatedPrice envuelve el precio en AnimatePresence con mode='popLayout': cada valor de precio único recibe su propio motion.span con key por precio, entrando desde y:24 y saliendo hacia y:-24 con un easing spring de 300ms. El badge de descuento usa un spring de escala que rebota por [0, 1.15, 1] al entrar. Las tarjetas se animan de forma escalonada con whileInView (retardo i * 0.08s).
Cómo crearlo en React
Construir el toggle spring
Crea un componente SpringToggle que reciba un booleano y un callback. Instancia un spring de Framer Motion y mapea su rango 0/1 al offset en píxeles donde debe situarse el thumb. Llama a spring.set() directamente en el cuerpo del render para que el thumb siga siempre a la prop.
const spring = useSpring(yearly ? 1 : 0, { stiffness: 500, damping: 35 }); const x = useTransform(spring, [0, 1], [3, 27]); spring.set(yearly ? 1 : 0); // drives the thumb on every renderAnimar el precio con AnimatePresence popLayout
Envuelve el span del precio en AnimatePresence con mode='popLayout' para que los elementos que salen se retiren del flujo del layout antes de que se posicionen los que entran. Asigna a cada motion.span una key con el valor del precio para que React trate el mensual y el anual como elementos distintos y dispare las animaciones de entrada y salida.
<AnimatePresence mode="popLayout"> <motion.span key={price} initial={{ y: 24, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -24, opacity: 0 }} transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }} style={{ display: "block", fontSize: "3rem", fontWeight: 800 }} > {price} </motion.span> </AnimatePresence>Hacer aparecer el badge de descuento con un rebote de escala
Renderiza el label de descuento dentro de AnimatePresence para que se monte y se desmonte con el estado yearly. Usa un array de animate para scale, [0, 1.15, 1], para producir un overshoot natural sin una config spring aparte.
<AnimatePresence> {yearly && ( <motion.span key="badge" initial={{ scale: 0, opacity: 0 }} animate={{ scale: [0, 1.15, 1], opacity: 1 }} exit={{ scale: 0, opacity: 0 }} transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }} > Save 20% </motion.span> )} </AnimatePresence>Construir las tarjetas de plan con entrada escalonada
Recorre el array tiers y renderiza cada tarjeta como un motion.div con whileInView. Multiplica el índice por 0.08 para el retardo, así las tarjetas entran en cascada de izquierda a derecha. Añade la prop layout para que Framer Motion gestione cualquier desplazamiento de layout durante la animación del precio.
<motion.div key={tier.name} layout initial={{ opacity: 0, y: 32 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.55, ease: E, delay: i * 0.08 }} >
Cuándo usarlo
Este patrón es la opción adecuada para productos SaaS que ofrecen facturación mensual y anual y necesitan hacer tangible el ahorro sin recargar la página. El precio animado dirige la mirada hacia la diferencia de valor; el toggle spring transmite una sensación premium sin resultar molesto. Evítalo en páginas de producto de e-commerce o en servicios de un solo plan, donde un toggle de facturación no tendría sentido. En móvil, tanto el thumb spring como la animación del precio se comportan bien, pero prueba el colapso del grid en viewports estrechos para verificar la legibilidad.
Usado por
- Linear, Usa un toggle mensual/anual con actualización instantánea del precio y un label de ahorro visible en el plan anual.
- Vercel, Cambio de periodo de facturación que actualiza los precios de los planes en línea, con un etiquetado claro del descuento anual en todos los planes.
- Lemon Squeezy, Toggle animado entre mensual y anual, badge de descuento en el label anual, que coincide exactamente con este patrón.
- Supabase, Cambio mensual/anual que actualiza de forma visible los precios por plan y destaca el ahorro en el plan anual.
FAQ
¿Por qué usar AnimatePresence popLayout en lugar de solo transicionar la opacidad?
popLayout retira el elemento saliente del flujo del layout de inmediato, así el precio entrante toma su posición correcta sin solaparse. Un simple cross-fade de opacidad mostraría los dos números apilados uno sobre otro durante la transición.
¿Cómo añado un cuarto plan sin romper el grid?
El grid usa repeat(auto-fit, minmax(280px, 1fr)), así que un cuarto plan encaja de forma natural en la misma fila en pantallas anchas o pasa a una segunda fila en las más estrechas. Aumenta la restricción maxWidth del contenedor del grid si quieres las cuatro tarjetas una al lado de otra.
¿Puedo controlar el toggle con un parámetro de URL para que la facturación anual se mantenga al navegar?
Sí. Sustituye el useState por un valor leído desde searchParams y actualiza la URL al hacer toggle en lugar de un estado local. La animación se dispara igualmente porque el spring se controla desde el valor de la prop, no desde un estado interno.
El spring del precio va lento en dispositivos Android de gama baja. ¿Cómo lo soluciono?
El componente AnimatedPrice solo usa opacity y transform (y), que se componen en la GPU y no deberían disparar layout. Si aun así ves tirones, envuelve el bloque del precio en un contenedor con will-change: transform o reduce el retardo de stagger de las tarjetas para recortar el tiempo total de animación.