El overlay más usado en MiCapi. Es el contenedor universal para confirmaciones (compra, retiro, swap), selectores (activo, método de pago) y avisos expandibles. Todo lo que en desktop sería modal o popover, en una app mobile-only es un bottom sheet. La spec de motion vive en foundations-motion §3c — esta página define el container, sus comportamientos y sus slots.
Seis elementos. El sheet siempre vive sobre un scrim y siempre tiene el handle arriba — incluso cuando no es draggable, el handle indica visualmente "esto es un sheet, no una pantalla".
Fondo negro con opacity 0.5 + blur sutil (backdrop-filter: blur(2px)). Cuando el sheet es dismissible, el scrim es tappable y cierra. Semantic label: "Cerrar".
Píldora 40dp × 4dp, color --color-neutral-300 (light) / --color-neutral-600 (dark), radius 2dp. La zona de toque es la franja completa de 40dp de alto — no solo el píldora visual.
Título + acción de cierre opcional. Alto 56dp. Cuando el body scrollea, aparece un divider hairline 1dp debajo del header para separarlo visualmente.
Contenido principal. Padding horizontal 16dp. SingleChildScrollView envuelve el body cuando el contenido supera el alto disponible. Header y footer permanecen fijos.
Botones de acción. Divider hairline arriba. Respeta safe area inferior — padding bottom suma MediaQuery.padding.bottom para no chocar con la barra de gesture nav.
border-radius: 16dp top-left + top-right. Abajo cuadrado — el sheet toca el borde inferior de la pantalla, no flota.
El mismo container; cambian las reglas de dismiss. Esta decisión nunca es estética — la define la naturaleza de la acción que el sheet contiene.
El default. Se cierra con tap en el scrim, swipe down, o botón X. Para acciones reversibles: selectores, tooltips expandidos, avisos informativos.
maxHeight 90% de la pantallashowModalBottomSheet(isDismissible: true, enableDrag: true)No se cierra con scrim ni con swipe. Solo con acción explícita: Confirmar o Cancelar. El usuario tiene que tomar una decisión.
showModalBottomSheet(isDismissible: false, enableDrag: false) + PopScope(canPop: false)El sheet define dónde va el contenido, no qué va. Los slots son tres: header (fijo arriba), body (scrollea), footer (fijo abajo). Los patterns concretos — confirmation, selector — se construyen rellenando estos slots.
Título de la acción + botón de cierre. 56dp de alto. Permanece arriba cuando el body scrollea. Aparece un divider 1dp debajo del header en cuanto el body se desplaza desde el top.
El contenido principal. Padding horizontal 16dp. SingleChildScrollView cuando excede el alto disponible. Header y footer no se mueven — solo el body.
Validación del container. Los patterns oficiales (Confirmation, Selector) se especifican aparte — esto solo prueba que la estructura escala bien con contenido real.
[0.1, 0.5, 0.9] indicados con líneas guía. Aquí se muestra a 90%, completamente abierto.Todos los valores vienen de foundations-motion. No hay haptic al abrir o cerrar el sheet — el haptic va dentro, en las acciones que el sheet contiene.
openduration-modalease-drawertranslateY 100% hasta 0. Backdrop fade 0 → 0.5 simultáneo. 300ms · Curves.easeOutCubic.closeduration-modalease-accelerate100%. Backdrop fade 0.5 → 0. La asimetría con el enter es intencional: salir se siente decidido.Duration.zeroposition = drag.deltaY.duration-defaultease-drawer200ms · Curves.easeOutCubic.duration-defaultopacity onlyMediaQuery.disableAnimationsOf(context): reemplaza slide por fade 200ms. Backdrop sigue oscureciendo igual.El sheet es un dialog semánticamente. Trap de foco, labels descriptivos, contraste AA, y siempre una ruta de cierre clara.
Semantics(label: 'Confirmar compra', container: true, scopesRoute: true, ...). TalkBack y VoiceOver anuncian la apertura con el título del header.
Mientras el sheet está abierto, Tab/keyboard nav queda dentro. Al cerrar, el foco vuelve al elemento que abrió el sheet — no se pierde.
"Arrastrar para ajustar" cuando el sheet es expandable. En sheets no-draggable el handle es excludeSemantics: true — no anunciar lo que no se puede hacer.
"Cerrar" cuando es dismissible. En persistente, el scrim excludeSemantics — no anunciar una acción inexistente.
Texto sobre superficie del sheet: contraste mínimo 4.5:1. Verificado para light y dark. Los chips de variación porcentual usan los semánticos del DS (success-fg, error-fg).
Dismissible: cierra el sheet (PopScope default). Persistente: bloqueado — el usuario debe usar "Cancelar". Bloquear con PopScope(canPop: false).
El scrim mantiene los mismos valores en ambos modos (rgba negro 0.5 + blur). Cambian la superficie del sheet, el color del handle y los tokens semánticos.
Tres situaciones que se rompen sin pensarlo. La que más se ve en producción es la del scroll — vale la pena demostrarla con un sheet de 10 ítems.
Ejemplo a la izquierda: 10 métodos de pago. El body scrollea pero el header con título y X, y el footer con el CTA, permanecen fijos. Apenas el body se separa del top, aparece un divider 1dp debajo del header.
Si el sheet contiene un TextField: Flutter lo maneja vía resizeToAvoidBottomInset: true en el Scaffold. El sheet se redimensiona automáticamente para no quedar tapado. El footer se mantiene visible justo encima del teclado.
No es prioritario (la app es portrait-primary). Si ocurre: el sheet limita su ancho a 70% máximo del ancho de pantalla y se centra horizontalmente. El scrim sigue cubriendo todo. Usar MediaQuery.orientation para el ajuste.
showModalBottomSheet con isScrollControlled: true + constraints: BoxConstraints(maxHeight: ...). El shape define el radius superior. Background con --surface.
isDismissible: false + enableDrag: false + envolver el body en PopScope(canPop: false) para bloquear el back de Android. Los botones explícitos son la única salida.
DraggableScrollableSheet(controller, snap: true, snapSizes: [0.3, 0.5, 0.9]). El scrollController interno permite scrollear el body sin interferir con el drag del sheet.
Column con tres hijos: SheetHeader, Expanded(child: SingleChildScrollView(body)), SheetFooter. El Expanded es lo que permite que solo el medio scrollee.
// Confirmation sheet — persistente, no dismissible await showModalBottomSheet<bool>( context: context, isScrollControlled: true, isDismissible: false, // scrim no cierra enableDrag: false, // swipe no cierra backgroundColor: Colors.transparent, builder: (ctx) => PopScope( canPop: false, // back de Android tampoco child: Container( decoration: BoxDecoration( color: Theme.of(ctx).colorScheme.surface, borderRadius: const BorderRadius.vertical( top: Radius.circular(16), ), ), child: SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ SheetHandle(), // visualmente sutil — no es draggable SheetHeader(title: 'Confirmar compra'), Flexible( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 16), child: BuyConfirmBody(amount: amount), ), ), SheetFooter( primary: PrimaryCta( label: 'Confirmar compra', onTap: () { HapticFeedback.mediumImpact(); Navigator.pop(ctx, true); }, ), secondary: SecondaryLink( label: 'Cancelar', onTap: () => Navigator.pop(ctx, false), ), ), ], ), ), ), ), );