Panel lateral de ajustes
Drawer sobre <dialog> que entra desde el lateral, con interruptores, selector y botones de guardar. Material oscuro.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useId, useRef, useState, type CSSProperties } from "react";
/** Panel lateral deslizante (drawer) de ajustes sobre <dialog> nativo: entra desde la derecha, con interruptores, selector y botón de guardar. Material oscuro. React + TypeScript, sin dependencias. */
export type CapaPanelProps = {
trigger?: string;
title?: string;
side?: "derecha" | "izquierda";
className?: string;
style?: CSSProperties;
};
const CSS = `
.gccpa-root { box-sizing: border-box; display: inline-block; font: 500 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; color: #e9ebf2; }
.gccpa-root *, .gccpa-root *::before, .gccpa-root *::after { box-sizing: border-box; }
.gccpa-root button, .gccpa-root select { font: inherit; }
.gccpa-root :focus-visible { outline: 2px solid #8fb4ff; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gccpa-dlg, .gccpa-dlg::backdrop { animation: none !important; } }
.gccpa-open { min-height: 44px; padding: 0 18px; border: 1px solid #2f3340; border-radius: 10px; background: #181a21; color: inherit; font-weight: 600; cursor: pointer; transition: background .15s; }
.gccpa-open:hover { background: #20232c; }
.gccpa-dlg { position: fixed; top: 0; bottom: 0; margin: 0; width: min(380px, 100vw); max-width: none; height: 100dvh; max-height: none; padding: 0; border: 0; background: #14161b; color: #e9ebf2; box-shadow: -24px 0 60px -20px rgba(0, 0, 0, .7); animation: gccpa-in .38s cubic-bezier(.16, 1, .3, 1); }
.gccpa-dlg[data-s="derecha"] { right: 0; left: auto; border-left: 1px solid #272a34; }
.gccpa-dlg[data-s="izquierda"] { left: 0; right: auto; border-right: 1px solid #272a34; --d: -100%; }
.gccpa-dlg::backdrop { background: rgba(5, 6, 10, .6); animation: gccpa-fade .3s ease-out; }
.gccpa-wrap { display: grid; grid-template-rows: auto 1fr auto; height: 100%; }
.gccpa-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px; border-bottom: 1px solid #23262f; }
.gccpa-title { margin: 0; font-size: 17px; font-weight: 700; }
.gccpa-x { display: grid; place-items: center; width: 36px; height: 36px; border: 0; border-radius: 8px; background: none; color: #9aa1b4; cursor: pointer; }
.gccpa-x:hover { background: #1d2029; color: #fff; }
.gccpa-body { overflow-y: auto; padding: 8px 20px; }
.gccpa-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 0; border-bottom: 1px solid #1f222a; }
.gccpa-row small { display: block; color: #8b92a5; font-size: 13px; }
.gccpa-sw { position: relative; flex: none; width: 46px; height: 28px; padding: 0; border: 0; border-radius: 999px; background: #2b2f3a; cursor: pointer; transition: background .2s; }
.gccpa-sw::after { content: ""; position: absolute; top: 3px; left: 3px; width: 22px; height: 22px; border-radius: 50%; background: #fff; transition: transform .25s cubic-bezier(.34, 1.4, .64, 1); }
.gccpa-sw[aria-checked="true"] { background: #4b7bff; }
.gccpa-sw[aria-checked="true"]::after { transform: translateX(18px); }
.gccpa-sel { height: 40px; padding: 0 10px; border: 1px solid #2f3340; border-radius: 8px; background: #1b1e26; color: inherit; }
.gccpa-foot { display: flex; gap: 10px; padding: 16px 20px; border-top: 1px solid #23262f; }
.gccpa-b { flex: 1; min-height: 44px; border: 1px solid #2f3340; border-radius: 10px; background: transparent; color: inherit; font-weight: 600; cursor: pointer; }
.gccpa-b:hover { background: #1d2029; }
.gccpa-b[data-p="true"] { border-color: #4b7bff; background: #4b7bff; color: #fff; }
.gccpa-b[data-p="true"]:hover { background: #6a92ff; }
@keyframes gccpa-in { from { transform: translateX(var(--d, 100%)); } }
@keyframes gccpa-fade { from { opacity: 0; } }`;
export function CapaPanel({ trigger = "Abrir ajustes", title = "Ajustes", side = "derecha", className = "", style }: CapaPanelProps) {
const id = useId();
const ref = useRef<HTMLDialogElement>(null);
const [opts, setOpts] = useState({ avisos: true, resumen: false, claro: false });
const sw = (key: keyof typeof opts, label: string, hint: string) => (
<div className="gccpa-row">
<span>
{label}
<small>{hint}</small>
</span>
<button className="gccpa-sw" type="button" role="switch" aria-checked={opts[key]} aria-label={label} onClick={() => setOpts((prev) => ({ ...prev, [key]: !prev[key] }))} />
</div>
);
return (
<div className={`gccpa-root ${className}`} style={style}>
<style>{CSS}</style>
<button className="gccpa-open" type="button" onClick={() => ref.current?.showModal()}>{trigger}</button>
<dialog ref={ref} className="gccpa-dlg" data-s={side} aria-labelledby={`${id}-t`} onClick={(event) => event.target === ref.current && ref.current?.close()}>
<div className="gccpa-wrap">
<div className="gccpa-head">
<h2 className="gccpa-title" id={`${id}-t`}>{title}</h2>
<button className="gccpa-x" type="button" aria-label="Cerrar panel" onClick={() => ref.current?.close()}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>
</button>
</div>
<div className="gccpa-body">
{sw("avisos", "Avisos por correo", "Recibe un correo con cada novedad")}
{sw("resumen", "Resumen semanal", "Los lunes a las 9:00")}
{sw("claro", "Tema claro", "Cambia la apariencia de la aplicación")}
<div className="gccpa-row">
<span>
Idioma
<small>Se aplica a toda la interfaz</small>
</span>
<select className="gccpa-sel" aria-label="Idioma" defaultValue="es">
<option value="es">Español</option>
<option value="en">English</option>
</select>
</div>
</div>
<div className="gccpa-foot">
<button className="gccpa-b" type="button" onClick={() => ref.current?.close()}>Cancelar</button>
<button className="gccpa-b" data-p="true" type="button" onClick={() => ref.current?.close()}>Guardar</button>
</div>
</div>
</dialog>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/capa-panelEjemplo de uso de Panel lateral de ajustes
Después de copiar capa-panel.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { CapaPanel } from "./capa-panel";
export default function Ejemplo() {
return <CapaPanel side="derecha" trigger="Ejemplo" />;
}Propiedades de CapaPanel
CapaPanel acepta 5 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
trigger? | string | Opcional. |
title? | string | Opcional. |
side? | "derecha" | "izquierda" | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Variantes disponibles
side:derecha,izquierda
Personaliza Panel lateral de ajustes con variables CSS
Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.
| Variable | Valor por defecto |
|---|---|
--d | 100% |
Accesibilidad y rendimiento
El archivo tiene 96 líneas (6 KB) y define 2 animaciones con @keyframes. Esto es lo que hace Panel lateral de ajustes 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: switch.
- Usa el elemento <dialog> nativo, con foco y cierre con Escape gestionados por el navegador.
- Es un componente de cliente de Next.js: incluye la directiva "use client".