Ir al contenido
Evaluar mi proyecto
Componentes · TarjetasDisponible

Reproductor neumorfismo

Disco que gira, barra hundida con avance y controles en relieve que se presionan hacia dentro. Reproducción simulada.

Horizonte nocturno

Marea Alta

0:383:34

Código completo

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

tarjeta-reproductor.tsx90 líneas
Descargar
"use client";

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

/** Reproductor neumorfismo: portada hundida, barra de progreso con avance simulado y controles en relieve. Sin audio real, para maquetar. React + TypeScript, sin dependencias. */
export type TarjetaReproductorProps = {
  title?: string;
  artist?: string;
  /** Duración simulada en segundos. */
  duration?: number;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gctrp-root { box-sizing: border-box; display: block; width: 100%; max-width: 330px; padding: 26px; border-radius: 32px; background: #e0e5ec; color: #46506a; font: 600 14px/1.3 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; box-shadow: 12px 12px 24px #a3b1c6, -12px -12px 24px #fff; }
.gctrp-root *, .gctrp-root *::before, .gctrp-root *::after { box-sizing: border-box; }
.gctrp-root button { font: inherit; -webkit-tap-highlight-color: transparent; }
@media (prefers-reduced-motion: reduce) { .gctrp-root *, .gctrp-root *::before, .gctrp-root *::after { animation: none !important; transition-duration: .01ms !important; } }
.gctrp-art { position: relative; display: grid; place-items: center; width: 168px; height: 168px; margin: 0 auto 22px; border-radius: 50%; background: #e0e5ec; box-shadow: inset 8px 8px 16px #a3b1c6, inset -8px -8px 16px #fff; }
.gctrp-disc { width: 132px; height: 132px; border-radius: 50%; background: conic-gradient(from 20deg, #5b8def, #8a6df0, #f06db6, #f0a56d, #5b8def); box-shadow: 6px 6px 12px #a3b1c6, -6px -6px 12px #fff; display: grid; place-items: center; animation: gctrp-spin 8s linear infinite; animation-play-state: paused; }
.gctrp-root[data-playing="true"] .gctrp-disc { animation-play-state: running; }
.gctrp-disc::after { content: ""; width: 34px; height: 34px; border-radius: 50%; background: #e0e5ec; box-shadow: inset 3px 3px 6px #a3b1c6, inset -3px -3px 6px #fff; }
.gctrp-title { margin: 0; text-align: center; font-size: 19px; font-weight: 800; color: #3a4560; }
.gctrp-artist { margin: 2px 0 18px; text-align: center; color: #7a86a1; }
.gctrp-bar { position: relative; height: 10px; margin-bottom: 6px; border-radius: 999px; background: #e0e5ec; box-shadow: inset 3px 3px 6px #a3b1c6, inset -3px -3px 6px #fff; cursor: pointer; }
.gctrp-fill { position: absolute; inset: 2px auto 2px 2px; border-radius: 999px; background: linear-gradient(90deg, #5b8def, #8a6df0); }
.gctrp-time { display: flex; justify-content: space-between; margin-bottom: 20px; font-size: 12px; color: #7a86a1; font-variant-numeric: tabular-nums; }
.gctrp-ctl { display: flex; justify-content: center; align-items: center; gap: 22px; }
.gctrp-b { display: grid; place-items: center; width: 52px; height: 52px; border: 0; border-radius: 50%; background: #e0e5ec; color: #46506a; cursor: pointer; box-shadow: 6px 6px 12px #a3b1c6, -6px -6px 12px #fff; transition: box-shadow .15s, transform .15s; }
.gctrp-b:hover { transform: translateY(-1px); }
.gctrp-b:active, .gctrp-b[aria-pressed="true"] { box-shadow: inset 4px 4px 8px #a3b1c6, inset -4px -4px 8px #fff; transform: none; }
.gctrp-b.main { width: 68px; height: 68px; color: #5b6bef; }
.gctrp-b:focus-visible { outline: 3px solid #5b8def; outline-offset: 3px; }
@keyframes gctrp-spin { to { transform: rotate(360deg); } }`;

const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;

export function TarjetaReproductor({ title = "Horizonte nocturno", artist = "Marea Alta", duration = 214, className = "", style }: TarjetaReproductorProps) {
  const [playing, setPlaying] = useState(false);
  const [t, setT] = useState(38);

  useEffect(() => {
    if (!playing) return;
    const id = window.setInterval(() => setT((value) => (value + 1 >= duration ? 0 : value + 1)), 1000);
    return () => window.clearInterval(id);
  }, [playing, duration]);

  return (
    <div className={`gctrp-root ${className}`} style={style} data-playing={playing}>
      <style>{CSS}</style>
      <div className="gctrp-art" aria-hidden="true">
        <div className="gctrp-disc" />
      </div>
      <h3 className="gctrp-title">{title}</h3>
      <p className="gctrp-artist">{artist}</p>
      <div className="gctrp-bar" role="slider" tabIndex={0} aria-label="Posición" aria-valuemin={0} aria-valuemax={duration} aria-valuenow={t} aria-valuetext={`${fmt(t)} de ${fmt(duration)}`}
        onClick={(event) => {
          const rect = event.currentTarget.getBoundingClientRect();
          setT(Math.round(((event.clientX - rect.left) / rect.width) * duration));
        }}
        onKeyDown={(event) => {
          if (event.key === "ArrowRight") { event.preventDefault(); setT((v) => Math.min(duration, v + 5)); }
          if (event.key === "ArrowLeft") { event.preventDefault(); setT((v) => Math.max(0, v - 5)); }
        }}>
        <span className="gctrp-fill" style={{ width: `calc(${(t / duration) * 100}% - 4px)` }} />
      </div>
      <div className="gctrp-time">
        <span>{fmt(t)}</span>
        <span>{fmt(duration)}</span>
      </div>
      <div className="gctrp-ctl">
        <button className="gctrp-b" type="button" aria-label="Anterior" onClick={() => setT(0)}>
          <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 5h2v14H6zM20 5v14L9 12z" /></svg>
        </button>
        <button className="gctrp-b main" type="button" aria-label={playing ? "Pausar" : "Reproducir"} aria-pressed={playing} onClick={() => setPlaying((value) => !value)}>
          {playing ? (
            <svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" /></svg>
          ) : (
            <svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z" /></svg>
          )}
        </button>
        <button className="gctrp-b" type="button" aria-label="Siguiente" onClick={() => setT(0)}>
          <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16 5h2v14h-2zM4 5l11 7L4 19z" /></svg>
        </button>
      </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/tarjeta-reproductor

Ejemplo de uso de Reproductor neumorfismo

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

ejemplo.tsx5 líneas
import { TarjetaReproductor } from "./tarjeta-reproductor";

export default function Ejemplo() {
  return <TarjetaReproductor title="Ejemplo" />;
}

Propiedades de TarjetaReproductor

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

PropiedadTipoDescripción
title?stringOpcional.
artist?stringOpcional.
duration?numberDuración simulada en segundos.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 90 líneas (6 KB) y define 1 animación con @keyframes. Esto es lo que hace Reproductor neumorfismo por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
  • Declara roles ARIA para lectores de pantalla: slider.
  • Responde al teclado, no solo al ratón.
  • 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)