Mundo Material 3: bandeja de entrada
Búsqueda, chips de filtro, lista con avatares que se seleccionan, estrella con rebote, barra de navegación y botón de redactar.
Buscar en el correo
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useState, type CSSProperties } from "react";
/** Mundo Material Design 3: bandeja de entrada con barra de búsqueda, chips de filtro, lista con avatares, estrella y selección, barra de navegación inferior y botón de redactar. Todo conmuta de verdad. React + TypeScript, sin dependencias. */
export type MundoMaterialProps = {
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcmma-root { box-sizing: border-box; container-type: inline-size; width: 100%; padding: clamp(14px, 4cqi, 36px); background: #e8e0f0; color: #1d1b20; font: 400 14px/20px Roboto, "Google Sans", ui-sans-serif, system-ui, sans-serif; letter-spacing: .25px; }
.gcmma-root *, .gcmma-root *::before, .gcmma-root *::after { box-sizing: border-box; }
.gcmma-root button { font: inherit; letter-spacing: inherit; -webkit-tap-highlight-color: transparent; }
.gcmma-root :focus-visible { outline: 3px solid #1d1b20; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gcmma-root * { transition-duration: .01ms !important; animation: none !important; } }
.gcmma-phone { position: relative; max-width: 400px; margin: 0 auto; border-radius: 28px; background: #fef7ff; overflow: hidden; box-shadow: 0 1px 3px 1px rgba(0, 0, 0, .15), 0 24px 40px -20px rgba(60, 30, 120, .4); }
.gcmma-search { display: flex; align-items: center; gap: 12px; height: 56px; margin: 16px 16px 8px; padding: 0 16px; border-radius: 28px; background: #ece6f0; color: #49454f; }
.gcmma-search span { flex: 1; }
.gcmma-av { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 50%; background: #6750a4; color: #fff; font-weight: 500; font-size: 14px; }
.gcmma-chips { display: flex; gap: 8px; padding: 4px 16px 10px; overflow-x: auto; scrollbar-width: none; }
.gcmma-chip { flex: none; display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: #49454f; font-weight: 500; letter-spacing: .1px; cursor: pointer; box-shadow: inset 0 0 0 1px #79747e; transition: background .2s; }
.gcmma-chip[aria-pressed="true"] { background: #e8def8; color: #1d192b; box-shadow: inset 0 0 0 1px transparent; }
.gcmma-list { margin: 0; padding: 0 0 82px; list-style: none; }
.gcmma-row { position: relative; display: grid; grid-template-columns: 40px 1fr auto; align-items: center; gap: 14px; width: 100%; padding: 12px 16px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; overflow: hidden; }
.gcmma-row::after { content: ""; position: absolute; inset: 0; background: #1d1b20; opacity: 0; transition: opacity .15s; pointer-events: none; }
.gcmma-row:hover::after { opacity: .06; }
.gcmma-row[aria-pressed="true"] { background: #e8def8; }
.gcmma-ava { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; background: var(--c); color: #1d1b20; font-weight: 500; font-size: 16px; transition: transform .3s cubic-bezier(.2, 0, 0, 1); }
.gcmma-row[aria-pressed="true"] .gcmma-ava { background: #6750a4; color: #fff; }
.gcmma-from { font-weight: 500; letter-spacing: .1px; }
.gcmma-row[data-unread="false"] .gcmma-from { font-weight: 400; }
.gcmma-sub { display: block; overflow: hidden; color: #49454f; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
.gcmma-star { position: relative; z-index: 1; display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 50%; background: transparent; color: #49454f; cursor: pointer; }
.gcmma-star:hover { background: rgba(29, 27, 32, .08); }
.gcmma-star[aria-pressed="true"] { color: #b3261e; }
.gcmma-star[aria-pressed="true"] svg { animation: gcmma-pop .4s cubic-bezier(.34, 1.56, .64, 1); fill: currentColor; }
.gcmma-fab { position: absolute; right: 16px; bottom: 80px; display: inline-flex; align-items: center; gap: 12px; height: 56px; padding: 0 20px 0 16px; border: 0; border-radius: 16px; background: #eaddff; color: #21005d; font-weight: 500; letter-spacing: .1px; cursor: pointer; box-shadow: 0 1px 3px 1px rgba(0, 0, 0, .15), 0 1px 2px rgba(0, 0, 0, .3); }
.gcmma-fab:hover { box-shadow: 0 1px 3px 1px rgba(0, 0, 0, .15), 0 2px 6px 2px rgba(0, 0, 0, .15); }
.gcmma-nav { position: absolute; left: 0; right: 0; bottom: 0; display: grid; grid-template-columns: repeat(3, 1fr); height: 72px; padding: 12px 8px 16px; background: #f3edf7; }
.gcmma-nb { display: grid; justify-items: center; gap: 4px; border: 0; background: none; color: #49454f; font-size: 12px; font-weight: 500; cursor: pointer; }
.gcmma-pill { display: grid; place-items: center; width: 64px; height: 32px; border-radius: 16px; transition: background .25s; }
.gcmma-nb[aria-selected="true"] .gcmma-pill { background: #e8def8; color: #1d192b; }
.gcmma-nb[aria-selected="true"] { color: #1d1b20; }
@keyframes gcmma-pop { 0% { transform: scale(.5); } 100% { transform: scale(1); } }`;
type M = { id: number; from: string; subject: string; unread: boolean; star: boolean; c: string };
const MAILS: M[] = [
{ id: 1, from: "Ana Pérez", subject: "Propuesta revisada: dos cambios menores en el alcance", unread: true, star: true, c: "#ffd8e4" },
{ id: 2, from: "Equipo de facturación", subject: "Tu factura de octubre ya está disponible", unread: true, star: false, c: "#c4eed0" },
{ id: 3, from: "Luis Gómez", subject: "Re: Reunión del jueves a las 10", unread: false, star: false, c: "#cfe6ff" },
{ id: 4, from: "Soporte", subject: "Tu solicitud #482 fue resuelta", unread: false, star: true, c: "#fff0c2" },
];
export function MundoMaterial({ className = "", style }: MundoMaterialProps) {
const [mails, setMails] = useState(MAILS);
const [filter, setFilter] = useState<"todos" | "no-leidos" | "destacados">("todos");
const [sel, setSel] = useState<number[]>([]);
const [tab, setTab] = useState("correo");
const shown = mails.filter((m) => (filter === "no-leidos" ? m.unread : filter === "destacados" ? m.star : true));
return (
<div className={`gcmma-root ${className}`} style={style}>
<style>{CSS}</style>
<div className="gcmma-phone">
<div className="gcmma-search">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true"><path d="M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.500-4.500" /></svg>
<span>Buscar en el correo</span>
<span className="gcmma-av" style={{ flex: "none" }} aria-hidden="true">L</span>
</div>
<div className="gcmma-chips" role="group" aria-label="Filtros">
{([["todos", "Todos"], ["no-leidos", "No leídos"], ["destacados", "Destacados"]] as const).map(([id, label]) => (
<button key={id} className="gcmma-chip" type="button" aria-pressed={filter === id} onClick={() => setFilter(id)}>
{filter === id ? <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m5 12.500 4.500 4.500L19 7.500" /></svg> : null}
{label}
</button>
))}
</div>
<ul className="gcmma-list">
{shown.map((m) => (
<li key={m.id}>
<div style={{ position: "relative" }}>
<button className="gcmma-row" type="button" data-unread={m.unread} aria-pressed={sel.includes(m.id)} onClick={() => setSel((s) => (s.includes(m.id) ? s.filter((x) => x !== m.id) : [...s, m.id]))}>
<span className="gcmma-ava" style={{ "--c": m.c } as CSSProperties}>{sel.includes(m.id) ? "✓" : m.from[0]}</span>
<span>
<span className="gcmma-from">{m.from}</span>
<span className="gcmma-sub">{m.subject}</span>
</span>
<span style={{ width: 40 }} />
</button>
<button className="gcmma-star" type="button" style={{ position: "absolute", right: 12, top: "50%", marginTop: -20 }} aria-pressed={m.star} aria-label={m.star ? "Quitar de destacados" : "Destacar"} onClick={() => setMails((list) => list.map((x) => (x.id === m.id ? { ...x, star: !x.star } : x)))}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" aria-hidden="true"><path d="m12 3 2.700 5.600 6.100.9-4.400 4.300 1 6.100L12 17l-5.400 2.900 1-6.100L3.200 9.500l6.100-.9z" /></svg>
</button>
</div>
</li>
))}
{shown.length === 0 ? <li style={{ padding: "28px 16px", color: "#49454f" }}>No hay mensajes con este filtro.</li> : null}
</ul>
<button className="gcmma-fab" type="button">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 20h4L19 9a2.800 2.800 0 0 0-4-4L4 16zM13.500 6.500l4 4" /></svg>
Redactar
</button>
<div className="gcmma-nav" role="tablist" aria-label="Secciones">
{[["correo", "Correo", "M4 6h16v12H4zM4 7l8 6 8-6"], ["chat", "Chat", "M4 5h16v11H9l-5 4z"], ["reuniones", "Reuniones", "M4 6h16v14H4zM4 10h16M8 3v4M16 3v4"]].map(([id, label, d]) => (
<button key={id} className="gcmma-nb" type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}>
<span className="gcmma-pill">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>
</span>
{label}
</button>
))}
</div>
</div>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/mundo-materialEjemplo de uso de Mundo Material 3: bandeja de entrada
Después de copiar mundo-material.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { MundoMaterial } from "./mundo-material";
export default function Ejemplo() {
return <MundoMaterial />;
}Propiedades de MundoMaterial
MundoMaterial acepta 2 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 116 líneas (10 KB) y define 1 animación con @keyframes. Esto es lo que hace Mundo Material 3: bandeja de entrada 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: group, tablist, tab.
- Es un componente de cliente de Next.js: incluye la directiva "use client".