Ir al contenido
Evaluar mi proyecto
Componentes · Progreso y cargaDisponible

Cargador líquido en frasco

Un frasco circular que se llena con dos olas desfasadas y burbujas que suben, con el porcentaje encima.

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

cargador-liquido.tsx60 líneas
Descargar
"use client";

import { useEffect, useId, useState, type CSSProperties } from "react";

/** Cargador líquido: un frasco circular que se llena con olas animadas y burbujas, con el porcentaje encima. Dos ondas SVG desfasadas, solo CSS. React + TypeScript, sin dependencias. */
export type CargadorLiquidoProps = {
  value?: number;
  color?: string;
  size?: number;
  simulate?: boolean;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcclq-root { box-sizing: border-box; display: inline-grid; justify-items: center; gap: 12px; color: #12304a; font: 800 14px/1.2 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcclq-root *, .gcclq-root *::before, .gcclq-root *::after { box-sizing: border-box; }
@media (prefers-reduced-motion: reduce) { .gcclq-root * { animation: none !important; transition-duration: .01ms !important; } }
.gcclq-jar { position: relative; width: var(--z); height: var(--z); overflow: hidden; border-radius: 50%; background: #f1f8ff; box-shadow: inset 0 0 0 5px #fff, inset 0 0 0 7px color-mix(in srgb, var(--c) 35%, #fff), 0 12px 26px -10px color-mix(in srgb, var(--c) 70%, #000); }
.gcclq-liq { position: absolute; left: 0; right: 0; bottom: 0; height: var(--h); transition: height .8s cubic-bezier(.16, 1, .3, 1); }
.gcclq-wave { position: absolute; left: 0; top: -14px; width: 200%; height: 28px; background-repeat: repeat-x; background-size: 50% 100%; animation: gcclq-slide var(--t) linear infinite; }
.gcclq-body { position: absolute; inset: 12px 0 0; background: var(--c); }
.gcclq-b1 { background-image: var(--w1); opacity: .55; --t: 5s; }
.gcclq-b2 { background-image: var(--w2); animation-direction: reverse; --t: 7s; top: -10px; }
.gcclq-bub { position: absolute; bottom: 8px; width: 8px; height: 8px; border-radius: 50%; background: rgba(255, 255, 255, .6); animation: gcclq-up var(--t) ease-in infinite; }
.gcclq-num { position: absolute; inset: 0; display: grid; place-items: center; font: 900 calc(var(--z) * .24)/1 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.04em; color: #0d3552; text-shadow: 0 1px 0 rgba(255, 255, 255, .7); font-variant-numeric: tabular-nums; }
@keyframes gcclq-slide { to { transform: translateX(-50%); } }
@keyframes gcclq-up { 0% { transform: translateY(0) scale(.6); opacity: 0; } 20% { opacity: 1; } 100% { transform: translateY(-120px) scale(1); opacity: 0; } }`;

const wavePath = (fill: string) => `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 28' preserveAspectRatio='none'><path d='M0 14 C 25 0 50 28 75 14 S 125 0 150 14 S 175 28 200 14 V28 H0Z' fill='${fill}'/></svg>")`;

export function CargadorLiquido({ value, color = "#2f9bff", size = 180, simulate = true, className = "", style }: CargadorLiquidoProps) {
  const id = useId();
  const [auto, setAuto] = useState(48);
  useEffect(() => {
    if (value !== undefined || !simulate || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const t = window.setInterval(() => !document.hidden && setAuto((v) => (v >= 100 ? 10 : Math.min(100, v + 5 + Math.random() * 9))), 1000);
    return () => window.clearInterval(t);
  }, [value, simulate]);
  const v = Math.round(Math.max(0, Math.min(100, value ?? auto)));
  const fill = color.replace("#", "%23");
  const vars = { "--z": `${size}px`, "--c": color, "--h": `${v}%`, "--w1": wavePath(fill), "--w2": wavePath(fill) } as CSSProperties;
  return (
    <div className={`gcclq-root ${className}`} style={{ ...vars, ...style }}>
      <style>{CSS}</style>
      <div className="gcclq-jar" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={v} aria-label="Carga" id={id}>
        <div className="gcclq-liq">
          <div className="gcclq-wave gcclq-b1" />
          <div className="gcclq-wave gcclq-b2" />
          <div className="gcclq-body" />
          {[18, 42, 66, 80].map((x, i) => (
            <span key={x} className="gcclq-bub" style={{ left: `${x}%`, "--t": `${2.2 + i * 0.7}s`, animationDelay: `${-i * 0.9}s` } as CSSProperties} />
          ))}
        </div>
        <span className="gcclq-num" aria-hidden="true">{v}%</span>
      </div>
    </div>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/cargador-liquido

Ejemplo de uso de Cargador líquido en frasco

Después de copiar cargador-liquido.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { CargadorLiquido } from "./cargador-liquido";

export default function Ejemplo() {
  return <CargadorLiquido color="Ejemplo" simulate />;
}

Propiedades de CargadorLiquido

CargadorLiquido acepta 6 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
value?numberOpcional.
color?stringOpcional.
size?numberOpcional.
simulate?booleanOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 60 líneas (4 KB) y define 2 animaciones con @keyframes. Esto es lo que hace Cargador líquido en frasco por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Declara roles ARIA para lectores de pantalla: progressbar.
  • Calcula tonos con color-mix(), así que un solo color base alcanza.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)