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.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/tarjeta-reproductorEjemplo de uso de Reproductor neumorfismo
Después de copiar tarjeta-reproductor.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
title? | string | Opcional. |
artist? | string | Opcional. |
duration? | number | Duración simulada en segundos. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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".