iosum ← Índice

Web & Frontend · LIVE

Luppo

Theme de Shopify DTC sobre mi propio sistema modular: PDP block-driven y motion sin jank.

  • Shopify Liquid
  • Web Components
  • CSS moderno (:has(), 3D)
  • GSAP + Lenis

Tienda de una marca de bienestar para mascotas, LIVE en producción. No es un theme de una sola tienda: es un fork de mi base modular, y lo interesante está en las decisiones de frontend, no en el catálogo.

Stack: Shopify Liquid · Web Components · CSS moderno (:has(), transforms 3D) · GSAP + Lenis · Estado: LIVE · Rol: diseño + desarrollo (solo)

El problema

Un theme de Shopify de la tienda de apps “se siente comprado”: galería genérica, page de producto rígida, animaciones que pelean con el scroll en móvil. Quería lo contrario — una página de producto que aguante comparación con una marca DTC seria (lens-zoom estilo Amazon, bundles con feedback táctil, motion que no haga jank) — pero sin escribir cada tienda desde cero. Así que el trabajo real no fue “hacer la tienda de Luppo”: fue mantener un sistema de theme propio del que esta tienda es una instancia.

Qué construí

Un theme modular de ~29 secciones (lp-*), todas block-driven y configurables desde el customizer de Shopify, sobre una base que reuso entre tiendas. Luppo es un fork: heredó la arquitectura, el loader de motion y las reglas de performance, y encima le puse paleta propia, una galería estilo Amazon y secciones editoriales (deep-dives de producto, value-math de bundle, advertorials).

La decisión de diseño de sistema que más rinde: la columna derecha de la página de producto no está hardcodeada. Itera section.blocks con un case/when por tipo (stars, quantity_break, subscribe_save, variant_selector, feature_bullet, accordion_item, trust_badge, cta…). El merchant arrastra y reordena los bloques en el customizer; el orden visual sale del orden del DOM. Una sección, N layouts de PDP sin tocar Liquid.

Eso es lo que separa “entregué una tienda” de “tengo un producto que despliego”: la siguiente marca arranca del mismo root y solo cambia configuración.

Decisiones de ingeniería frontend interesantes

Lens-zoom estilo Amazon, calculado en JS porque CSS puro no puede. Al hacer hover sobre la imagen principal, un lens de 140px sigue el cursor y un panel a la derecha muestra la imagen ampliada (source de 1600px, zoom 2.5×). El ancho del panel se calcula en runtime: (window.innerWidth - rect.left - 24) * 0.8, capeado entre 400 y 900px. La razón de hacerlo en JS y no en CSS está en mi CLAUDE.md: la sección está centrada, así que 100vw - 100% no resta el margen izquierdo real — solo getBoundingClientRect() da la posición verdadera del panel para no desbordar el viewport.

El overlay móvil tiene que escapar de su ancestro — y eso es una línea load-bearing. En móvil, tap en la imagen abre una galería vertical full-screen con position: fixed. Pero el contenedor de la galería tiene data-gsap-animate, y GSAP le aplica un transform al ancestro. Un ancestro transformado se vuelve el bloque contenedor de cualquier descendiente fixed: el overlay se renderiza inline en la página en vez de cubrir la pantalla. El fix es un document.body.appendChild(vgallery) en el init — portalear el nodo a <body> para salir del stacking context transformado. Lo documenté como crítico precisamente porque es el tipo de bug que parece imposible hasta que entiendes que transform crea contexto de contención.

Bundles con transforms 3D y el sibling selector :has(). Los quantity-breaks viven en un contenedor con perspective: 1200px y transform-style: preserve-3d. En hover, la opción seleccionada se levanta del plano (translateZ + translateY negativo) con un easing tipo resorte (cubic-bezier(0.34, 1.56, 0.64, 1)) y sombra proyectada — feedback físico, no un simple cambio de color. Lo que lo redondea: .lp-qty-break:has(~ .lp-qty-break:hover) hunde ligeramente las opciones anteriores a la que tiene hover, así que el grupo entero reacciona al cursor. Toda la animación es solo transform + box-shadow (compositable en GPU), gateada con @media (hover: hover) and (pointer: fine), y anulada bajo prefers-reduced-motion: reduce.

Motion con GSAP + Lenis, con bailout para hardware que no aguanta. El loader (lp-motion.js) trae GSAP, ScrollTrigger y Lenis desde CDN solo si la sección activa los pide, con un timeout de 8s como failsafe y .catch(() => fallback()) que marca todo visible si el CDN falla. Dos decisiones que me gustan ahí:

  • Bailout de gama baja: if (innerWidth <= 768 && navigator.hardwareConcurrency <= 2) return fallback(). Móvil con ≤2 cores no recibe scroll-jank; recibe la página estática. Smooth scroll que tartamudea es peor que no tenerlo.
  • Capability gating real: if (CSS.supports('animation-timeline: view()')) cards = false. Si el navegador hace scroll-driven animations nativas en CSS, no cargo el JS de GSAP para las cards — dejo que la plataforma lo haga. Enhancement progresivo de verdad, no un polyfill que pesa siempre.

Performance forense en el video del hero. El atributo HTML autoplay no es confiable — iOS/Safari lo pausa en silencio. El recovery script cubre IntersectionObserver, canplay, suspend (reintento cuando el browser corta la descarga), visibilitychange, y handlers touchstart/click persistentes (sin { once: true }) que solo se quitan cuando play() resuelve. El LCP se gana con un truco de poster: un <img> de 800px con fetchpriority="high" se pinta detrás del <video> (~200ms) y se oculta con display:none en el evento playing; el video carga con preload="metadata" (≈200KB en vez de 3MB+) y se sube a preload="auto" vía requestIdleCallback para no competir con el poster.

Gotchas de Liquid que documenté para no repetirlos. El filtro image_url no acepta cadenas de filtros anidadas en sus argumentos (width: 400, height: x | times: 400 ... revienta con “wrong number of arguments”); hay que precomputar la variable antes con un assign. Y la jerarquía de carga de CSS es regla dura: base.css se carga una sola vez en theme.liquid, las secciones above-the-fold van render-blocking + preload, y todo lo below-fold se carga con media="print" onload="this.media='all'" — cada hoja bloqueante suma 100-300ms al FCP en móvil.

Qué demuestra

  • Construyo sistemas, no entregables. Page de producto block-driven, base reutilizable entre marcas, configuración sobre código. El segundo cliente es casi gratis.
  • Entiendo el porqué, no solo el qué. Stacking contexts y contención por transform, por qué flex gana a grid para alinear pares, por qué el zoom se calcula en JS, por qué autoplay no basta en iOS. El CLAUDE.md del theme es un registro de esas decisiones, no una lista de features.
  • Performance como restricción de primera clase. Bailout por número de cores, gating por CSS.supports, jerarquía de CSS crítico, trucos de LCP/poster — decisiones medidas en milisegundos de FCP/LCP en móvil, no estética.
  • CSS moderno donde paga: :has() para reacción de grupo, transforms 3D compositables, hover/reduced-motion gateados por defecto.

Estado y siguiente paso

LIVE en producción. El root modular ya es mi base por defecto para lanzamientos DTC; cada tienda nueva hereda performance y motion y solo aporta paleta, contenido y secciones específicas. Pendiente que me interesa: convertir el CLAUDE.md del theme en un linter de pre-push (above/below-fold, base.css duplicado, recovery de autoplay en videos nuevos) para que las reglas se chequeen solas en vez de vivir en un checklist.