Ir al contenido
Evaluar mi proyecto
Componentes · Mundos de estiloDisponible

Mundo neumorfismo: casa inteligente

Termostato giratorio, interruptores hundidos, regulador y escenas, todo extruido en una sola superficie gris. Controles reales.

Termostato

Luces

Sala
Cocina
Jardín

Escenas

Código completo

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

mundo-neumorfismo.tsx120 líneas
Descargar
"use client";

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

/** Mundo neumorfismo: panel de casa inteligente con termostato giratorio, interruptores, regulador y escenas, todo extruido en una sola superficie gris. React + TypeScript, sin dependencias. */
export type MundoNeumorfismoProps = {
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcmnu-root { box-sizing: border-box; container-type: inline-size; width: 100%; padding: clamp(18px, 4cqi, 40px); background: #e0e5ec; color: #4b556b; font: 600 14px/1.3 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcmnu-root *, .gcmnu-root *::before, .gcmnu-root *::after { box-sizing: border-box; }
.gcmnu-root button { font: inherit; -webkit-tap-highlight-color: transparent; }
.gcmnu-root :focus-visible { outline: 3px solid #5b8def; outline-offset: 4px; }
@media (prefers-reduced-motion: reduce) { .gcmnu-root * { transition-duration: .01ms !important; } }
.gcmnu-grid { display: grid; grid-template-columns: 1.1fr 1fr; gap: 28px; max-width: 760px; margin: 0 auto; }
.gcmnu-panel { padding: 24px; border-radius: 28px; box-shadow: 10px 10px 20px #a3b1c6, -10px -10px 20px #fff; }
.gcmnu-h { margin: 0 0 18px; font-size: 13px; letter-spacing: .1em; text-transform: uppercase; color: #7a86a1; }
.gcmnu-dialwrap { display: grid; justify-items: center; gap: 14px; }
.gcmnu-dial { position: relative; width: 200px; height: 200px; border-radius: 50%; box-shadow: 10px 10px 20px #a3b1c6, -10px -10px 20px #fff; cursor: grab; touch-action: none; outline: 0; }
.gcmnu-dial:focus-visible { box-shadow: 10px 10px 20px #a3b1c6, -10px -10px 20px #fff, 0 0 0 3px #5b8def; }
.gcmnu-dial::before { content: ""; position: absolute; inset: 22px; border-radius: 50%; box-shadow: inset 6px 6px 12px #a3b1c6, inset -6px -6px 12px #fff; }
.gcmnu-knob { position: absolute; inset: 40px; border-radius: 50%; background: linear-gradient(145deg, #eef2f8, #d3d9e3); box-shadow: 6px 6px 12px #a3b1c6, -6px -6px 12px #fff; transition: transform .1s linear; }
.gcmnu-knob::after { content: ""; position: absolute; left: 50%; top: 8px; width: 8px; height: 8px; margin-left: -4px; border-radius: 50%; background: #5b8def; box-shadow: 0 0 6px rgba(91, 141, 239, .7); }
.gcmnu-temp { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; pointer-events: none; }
.gcmnu-temp b { font: 800 40px/1 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.04em; color: #3b4658; font-variant-numeric: tabular-nums; }
.gcmnu-temp span { font-size: 12px; color: #7a86a1; }
.gcmnu-pm { display: flex; gap: 14px; }
.gcmnu-b { display: grid; place-items: center; width: 48px; height: 48px; border: 0; border-radius: 50%; background: #e0e5ec; color: #4b556b; font-size: 22px; cursor: pointer; box-shadow: 5px 5px 10px #a3b1c6, -5px -5px 10px #fff; transition: box-shadow .15s; }
.gcmnu-b:active { box-shadow: inset 4px 4px 8px #a3b1c6, inset -4px -4px 8px #fff; }
.gcmnu-col { display: grid; gap: 18px; align-content: start; }
.gcmnu-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
.gcmnu-sw { position: relative; width: 62px; height: 32px; padding: 0; border: 0; border-radius: 999px; background: #e0e5ec; cursor: pointer; box-shadow: inset 4px 4px 8px #a3b1c6, inset -4px -4px 8px #fff; }
.gcmnu-sw::after { content: ""; position: absolute; top: 4px; left: 4px; width: 24px; height: 24px; border-radius: 50%; background: linear-gradient(145deg, #f0f4fa, #d3d9e3); box-shadow: 3px 3px 6px #a3b1c6, -2px -2px 5px #fff; transition: transform .3s cubic-bezier(.34, 1.4, .64, 1); }
.gcmnu-sw[aria-checked="true"]::after { transform: translateX(30px); background: linear-gradient(145deg, #7ea4f6, #5b8def); }
.gcmnu-range { -webkit-appearance: none; appearance: none; width: 100%; height: 12px; margin: 0; border-radius: 999px; background: #e0e5ec; box-shadow: inset 3px 3px 6px #a3b1c6, inset -3px -3px 6px #fff; outline: 0; }
.gcmnu-range::-webkit-slider-thumb { -webkit-appearance: none; width: 26px; height: 26px; border-radius: 50%; background: linear-gradient(145deg, #f0f4fa, #d3d9e3); box-shadow: 4px 4px 8px #a3b1c6, -3px -3px 6px #fff; border: 0; }
.gcmnu-range::-moz-range-thumb { width: 26px; height: 26px; border-radius: 50%; background: linear-gradient(145deg, #f0f4fa, #d3d9e3); box-shadow: 4px 4px 8px #a3b1c6, -3px -3px 6px #fff; border: 0; }
.gcmnu-scenes { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.gcmnu-sc { min-height: 54px; border: 0; border-radius: 16px; background: #e0e5ec; color: #4b556b; cursor: pointer; box-shadow: 5px 5px 10px #a3b1c6, -5px -5px 10px #fff; transition: box-shadow .15s, color .15s; }
.gcmnu-sc[aria-pressed="true"] { box-shadow: inset 4px 4px 8px #a3b1c6, inset -4px -4px 8px #fff; color: #5b8def; }
@container (max-width: 640px) { .gcmnu-grid { grid-template-columns: minmax(0, 1fr); } }`;

export function MundoNeumorfismo({ className = "", style }: MundoNeumorfismoProps) {
  const [temp, setTemp] = useState(22);
  const [lights, setLights] = useState({ sala: true, cocina: false, jardin: true });
  const [bright, setBright] = useState(70);
  const [scene, setScene] = useState("dia");
  const dial = useRef<HTMLDivElement>(null);
  const MIN = 16, MAX = 30;
  const angle = ((temp - MIN) / (MAX - MIN)) * 270 - 135;

  function fromPointer(event: PointerEvent<HTMLDivElement>) {
    const r = dial.current!.getBoundingClientRect();
    const deg = (Math.atan2(event.clientX - (r.left + r.width / 2), -(event.clientY - (r.top + r.height / 2))) * 180) / Math.PI;
    const clamped = Math.max(-135, Math.min(135, deg));
    setTemp(Math.round(MIN + ((clamped + 135) / 270) * (MAX - MIN)));
  }
  function key(event: KeyboardEvent) {
    if (event.key === "ArrowUp" || event.key === "ArrowRight") { event.preventDefault(); setTemp((v) => Math.min(MAX, v + 1)); }
    if (event.key === "ArrowDown" || event.key === "ArrowLeft") { event.preventDefault(); setTemp((v) => Math.max(MIN, v - 1)); }
  }
  const sw = (k: keyof typeof lights, label: string) => (
    <div className="gcmnu-row">
      <span>{label}</span>
      <button className="gcmnu-sw" type="button" role="switch" aria-checked={lights[k]} aria-label={label} onClick={() => setLights((p) => ({ ...p, [k]: !p[k] }))} />
    </div>
  );

  return (
    <div className={`gcmnu-root ${className}`} style={style}>
      <style>{CSS}</style>
      <div className="gcmnu-grid">
        <section className="gcmnu-panel" aria-label="Termostato">
          <h3 className="gcmnu-h">Termostato</h3>
          <div className="gcmnu-dialwrap">
            <div ref={dial} className="gcmnu-dial" role="slider" tabIndex={0} aria-label="Temperatura" aria-valuemin={MIN} aria-valuemax={MAX} aria-valuenow={temp} aria-valuetext={`${temp} grados`} onKeyDown={key} onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); fromPointer(e); }} onPointerMove={(e) => e.buttons === 1 && fromPointer(e)}>
              <div className="gcmnu-knob" style={{ transform: `rotate(${angle}deg)` }} />
              <div className="gcmnu-temp" aria-hidden="true">
                <b>{temp}°</b>
                <span>Sala</span>
              </div>
            </div>
            <div className="gcmnu-pm">
              <button className="gcmnu-b" type="button" aria-label="Bajar temperatura" onClick={() => setTemp((v) => Math.max(MIN, v - 1))}>−</button>
              <button className="gcmnu-b" type="button" aria-label="Subir temperatura" onClick={() => setTemp((v) => Math.min(MAX, v + 1))}>+</button>
            </div>
          </div>
        </section>
        <div className="gcmnu-col">
          <section className="gcmnu-panel" aria-label="Luces">
            <h3 className="gcmnu-h">Luces</h3>
            <div style={{ display: "grid", gap: 16 }}>
              {sw("sala", "Sala")}
              {sw("cocina", "Cocina")}
              {sw("jardin", "Jardín")}
              <div>
                <label className="gcmnu-row" htmlFor="gcmnu-br" style={{ marginBottom: 10 }}>
                  <span>Intensidad</span>
                  <span style={{ fontVariantNumeric: "tabular-nums" }}>{bright}%</span>
                </label>
                <input id="gcmnu-br" className="gcmnu-range" type="range" min={10} max={100} value={bright} onChange={(e) => setBright(Number(e.target.value))} />
              </div>
            </div>
          </section>
          <section className="gcmnu-panel" aria-label="Escenas">
            <h3 className="gcmnu-h">Escenas</h3>
            <div className="gcmnu-scenes">
              {[["dia", "Día"], ["cine", "Cine"], ["noche", "Noche"]].map(([id, label]) => (
                <button key={id} className="gcmnu-sc" type="button" aria-pressed={scene === id} onClick={() => setScene(id)}>{label}</button>
              ))}
            </div>
          </section>
        </div>
      </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/mundo-neumorfismo

Ejemplo de uso de Mundo neumorfismo: casa inteligente

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

ejemplo.tsx5 líneas
import { MundoNeumorfismo } from "./mundo-neumorfismo";

export default function Ejemplo() {
  return <MundoNeumorfismo />;
}

Propiedades de MundoNeumorfismo

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

PropiedadTipoDescripción
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 120 líneas (9 KB) y no necesita @keyframes. Esto es lo que hace Mundo neumorfismo: casa inteligente 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, slider.
  • Responde al teclado, no solo al ratón.
  • Reacciona al puntero con eventos pointer (ratón, lápiz y táctil).
  • 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)