Ir al contenido
Evaluar mi proyecto
Componentes · TarjetasDisponible

Tarjetas Material Design 3

Tarjetas elevated, filled y outlined con capa de estado, ondulación al tocar y bloque que se expande.

Código completo

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

tarjeta-material.tsx94 líneas
Descargar
"use client";

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

/** Tarjetas Material Design 3 (elevated, filled, outlined) con capa de estado, ondulación y bloque expandible. React + TypeScript, sin dependencias. */
export type TarjetaMaterialProps = {
  variant?: "elevated" | "filled" | "outlined";
  title?: string;
  subtitle?: string;
  text?: string;
  expandable?: string;
  href?: string;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gctmt-root { box-sizing: border-box; display: block; width: 100%; max-width: 360px; font: 400 14px/20px Roboto, "Google Sans", ui-sans-serif, system-ui, sans-serif; letter-spacing: .25px; color: #1d1b20; }
.gctmt-root *, .gctmt-root *::before, .gctmt-root *::after { box-sizing: border-box; }
.gctmt-root button { font: inherit; letter-spacing: inherit; }
@media (prefers-reduced-motion: reduce) { .gctmt-root *, .gctmt-root *::before, .gctmt-root *::after { animation: none !important; transition-duration: .01ms !important; } }
.gctmt-card { position: relative; overflow: hidden; isolation: isolate; border-radius: 12px; background: #fff; transition: box-shadow .2s cubic-bezier(.2, 0, 0, 1); }
.gctmt-card[data-v="elevated"] { background: #f7f2fa; box-shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 1px 3px 1px rgba(0, 0, 0, .15); }
.gctmt-card[data-v="elevated"]:hover { box-shadow: 0 1px 3px 1px rgba(0, 0, 0, .15), 0 2px 6px 2px rgba(0, 0, 0, .15); }
.gctmt-card[data-v="filled"] { background: #e7e0ec; }
.gctmt-card[data-v="outlined"] { background: #fef7ff; box-shadow: inset 0 0 0 1px #cac4d0; }
.gctmt-main { position: relative; display: block; width: 100%; padding: 16px; text-align: left; color: inherit; text-decoration: none; cursor: pointer; overflow: hidden; }
.gctmt-main::after { content: ""; position: absolute; inset: 0; background: #1d1b20; opacity: 0; transition: opacity .15s; pointer-events: none; }
.gctmt-main:hover::after { opacity: .08; }
.gctmt-main:focus-visible { outline: 3px solid #6750a4; outline-offset: -3px; }
.gctmt-main:focus-visible::after { opacity: .1; }
.gctmt-ripple { position: absolute; border-radius: 50%; background: #1d1b20; opacity: .12; pointer-events: none; transform: scale(0); animation: gctmt-ripple .55s cubic-bezier(.2, 0, 0, 1) forwards; }
.gctmt-media { height: 140px; margin: -16px -16px 16px; background: linear-gradient(135deg, #d0bcff, #6750a4); }
.gctmt-title { margin: 0; font: 400 22px/28px Roboto, "Google Sans", ui-sans-serif, sans-serif; letter-spacing: 0; }
.gctmt-sub { margin: 0 0 12px; color: #49454f; font-size: 14px; }
.gctmt-text { margin: 0; color: #49454f; }
.gctmt-foot { display: flex; align-items: center; justify-content: flex-end; padding: 0 16px 16px; }
.gctmt-more { display: inline-flex; align-items: center; gap: 4px; height: 40px; padding: 0 12px; border: 0; border-radius: 20px; background: none; color: #6750a4; font-weight: 500; letter-spacing: .1px; cursor: pointer; }
.gctmt-more:hover { background: rgba(103, 80, 164, .08); }
.gctmt-more:focus-visible { outline: 3px solid #6750a4; outline-offset: 2px; }
.gctmt-more svg { transition: transform .3s cubic-bezier(.2, 0, 0, 1); }
.gctmt-more[aria-expanded="true"] svg { transform: rotate(180deg); }
.gctmt-extra { display: grid; grid-template-rows: 0fr; padding: 0 16px; transition: grid-template-rows .3s cubic-bezier(.2, 0, 0, 1); color: #49454f; }
.gctmt-extra[data-open="true"] { grid-template-rows: 1fr; }
.gctmt-extra > div { overflow: hidden; }
.gctmt-extra p { margin: 0 0 16px; }
@keyframes gctmt-ripple { to { transform: scale(1); opacity: 0; } }`;

type Ripple = { id: number; x: number; y: number; s: number };

export function TarjetaMaterial({ variant = "elevated", title = "Resumen semanal", subtitle = "Actualizado hoy", text = "Tus proyectos activos avanzan según lo planeado. Dos entregas esperan revisión.", expandable = "Las entregas pendientes son el módulo de reportes y la integración con el sistema de facturación.", className = "", style, href = "#" }: TarjetaMaterialProps) {
  const uid = useId();
  const [open, setOpen] = useState(false);
  const [ripples, setRipples] = useState<Ripple[]>([]);

  function spawn(event: PointerEvent<HTMLAnchorElement>) {
    const rect = event.currentTarget.getBoundingClientRect();
    const s = Math.hypot(rect.width, rect.height) * 2;
    const id = Date.now() + Math.random();
    setRipples((list) => [...list.slice(-2), { id, s, x: event.clientX - rect.left - s / 2, y: event.clientY - rect.top - s / 2 }]);
    window.setTimeout(() => setRipples((list) => list.filter((item) => item.id !== id)), 600);
  }

  return (
    <div className={`gctmt-root ${className}`} style={style}>
      <style>{CSS}</style>
      <article className="gctmt-card" data-v={variant}>
        <a className="gctmt-main" href={href} onPointerDown={spawn} onClick={(event) => href === "#" && event.preventDefault()}>
          {ripples.map((r) => (
            <span key={r.id} className="gctmt-ripple" style={{ left: r.x, top: r.y, width: r.s, height: r.s }} />
          ))}
          <span className="gctmt-media" style={{ display: "block" }} aria-hidden="true" />
          <h3 className="gctmt-title">{title}</h3>
          <p className="gctmt-sub">{subtitle}</p>
          <p className="gctmt-text">{text}</p>
        </a>
        <div className="gctmt-extra" data-open={open} id={`${uid}-x`} aria-hidden={!open}>
          <div>
            <p>{expandable}</p>
          </div>
        </div>
        <div className="gctmt-foot">
          <button className="gctmt-more" type="button" aria-expanded={open} aria-controls={`${uid}-x`} onClick={() => setOpen((value) => !value)}>
            {open ? "Menos" : "Más"}
            <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="m6 9 6 6 6-6" />
            </svg>
          </button>
        </div>
      </article>
    </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-material

Ejemplo de uso de Tarjetas Material Design 3

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

ejemplo.tsx5 líneas
import { TarjetaMaterial } from "./tarjeta-material";

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

Propiedades de TarjetaMaterial

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

PropiedadTipoDescripción
variant?"elevated" | "filled" | "outlined"Opcional.
title?stringOpcional.
subtitle?stringOpcional.
text?stringOpcional.
expandable?stringOpcional.
href?stringOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Variantes disponibles

  • variant: elevated, filled, outlined

Accesibilidad y rendimiento

El archivo tiene 94 líneas (6 KB) y define 1 animación con @keyframes. Esto es lo que hace Tarjetas Material Design 3 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).
  • Expone su estado a tecnologías de apoyo con atributos aria-*.
  • 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)