Button
El nodo que se pulsa. Convierte un toque, un Enter o un botón del mando en una named action que el juego escucha.
primitivedesde v1focusable
Un Button es el nodo que el jugador pulsa. Lo usas
siempre que un gesto tiene que convertirse en una decisión sobre la que el juego
actúa: empezar un nivel, confirmar una compra, cerrar un diálogo. Piensa en el
menú principal: la fila Play es un Button que lleva la
named action "play", y al pulsarla el juego oye esa
palabra y nada más. Nunca se entera de qué aspecto tenía el botón, y el botón
nunca se entera de qué hizo el juego con ella.

- hover — inactivo
- pressed — inactivo
- focused — inactivo
- selected — inactivo
- disabled — inactivo
Se lee del último frame dibujado
Parado: pulsa Ejecutar para dibujarlo en la GPU.
import { Button, Column, Row, Text } from "@zabloo/react";
export default function ButtonVariants() {
return (
<Column
layout={{
grow: 1,
justify: "center",
align: "center",
gap: "{space.5}",
padding: "{space.6}",
}}
style={{ background: "{color.bg}" }}
>
<Row layout={{ gap: "{space.3}", align: "center" }}>
<Button
variant="primary"
onClick="play"
layout={{ padding: "{space.3}", width: 132, justify: "center", align: "center" }}
>
<Text style={{ color: "{color.on-brand}", fontSize: "{text.md}" }}>Play</Text>
</Button>
<Button
variant="secondary"
onClick="options"
layout={{ padding: "{space.3}", width: 132, justify: "center", align: "center" }}
>
<Text style={{ color: "{color.text}", fontSize: "{text.md}" }}>Options</Text>
</Button>
<Button
variant="secondary"
disabled
onClick="quit"
layout={{ padding: "{space.3}", width: 132, justify: "center", align: "center" }}
>
<Text style={{ color: "{color.text}", fontSize: "{text.md}" }}>Quit</Text>
</Button>
</Row>
<Text style={{ color: "{color.faint}", fontSize: "{text.xs}" }}>
primary · secondary · secondary disabled
</Text>
</Column>
);
}{
"v": 1,
"tokens": {
"color.bg": "#0b0d13",
"color.surface": "#0e1016",
"color.raised": "#14141a",
"color.line": "#ffffff1f",
"color.line-strong": "#ffffff24",
"color.text": "#ffffff",
"color.muted": "#a1a1aa",
"color.faint": "#8a8a93",
"color.on-brand": "#ffffff",
"color.brand": "#8b5cf6",
"color.brand-strong": "#7c3aed",
"color.brand-hover": "#8b5cf6",
"color.brand-pressed": "#6d28d9",
"color.brand-soft": "#8b5cf61f",
"color.gold": "#fcd34d",
"color.slot": "#ffffff08",
"color.danger": "#f87171",
"radius.sm": 6,
"radius.md": 10,
"radius.lg": 14,
"radius.pill": 999,
"border.hairline": 1,
"border.focus": 2,
"space.1": 4,
"space.2": 8,
"space.3": 12,
"space.4": 16,
"space.5": 20,
"space.6": 24,
"text.xs": 11,
"text.sm": 13,
"text.md": 15,
"text.lg": 17,
"motion.fast": 120,
"motion.loop": 900
},
"views": {
"button-variants": {
"type": "Container",
"layout": {
"direction": "column",
"grow": 1,
"justify": "center",
"align": "center",
"gap": "{space.5}",
"padding": "{space.6}"
},
"style": {
"background": "{color.bg}"
},
"children": [
{
"type": "Container",
"layout": {
"direction": "row",
"gap": "{space.3}",
"align": "center"
},
"children": [
{
"type": "Button",
"layout": {
"padding": "{space.3}",
"width": 132,
"justify": "center",
"align": "center"
},
"style": {
"background": "{color.brand-strong}",
"radius": "{radius.md}",
"color": "{color.on-brand}"
},
"states": {
"hover": {
"style": {
"background": "{color.brand-hover}"
}
},
"pressed": {
"style": {
"background": "{color.brand-pressed}"
}
},
"focused": {
"style": {
"borderWidth": "{border.focus}",
"borderColor": "{color.brand}"
}
},
"disabled": {
"style": {
"opacity": 0.45
}
}
},
"transition": {
"duration": "{motion.fast}"
},
"onClick": "play",
"children": [
{
"type": "Text",
"style": {
"color": "{color.on-brand}",
"fontSize": "{text.md}"
},
"text": "Play"
}
]
},
{
"type": "Button",
"layout": {
"padding": "{space.3}",
"width": 132,
"justify": "center",
"align": "center"
},
"style": {
"background": "#00000000",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line-strong}",
"radius": "{radius.md}",
"color": "{color.text}"
},
"states": {
"hover": {
"style": {
"background": "{color.slot}"
}
},
"pressed": {
"style": {
"background": "{color.brand-soft}"
}
},
"focused": {
"style": {
"borderWidth": "{border.focus}",
"borderColor": "{color.brand}"
}
},
"disabled": {
"style": {
"opacity": 0.45
}
}
},
"transition": {
"duration": "{motion.fast}"
},
"onClick": "options",
"children": [
{
"type": "Text",
"style": {
"color": "{color.text}",
"fontSize": "{text.md}"
},
"text": "Options"
}
]
},
{
"type": "Button",
"disabled": true,
"layout": {
"padding": "{space.3}",
"width": 132,
"justify": "center",
"align": "center"
},
"style": {
"background": "#00000000",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line-strong}",
"radius": "{radius.md}",
"color": "{color.text}"
},
"states": {
"hover": {
"style": {
"background": "{color.slot}"
}
},
"pressed": {
"style": {
"background": "{color.brand-soft}"
}
},
"focused": {
"style": {
"borderWidth": "{border.focus}",
"borderColor": "{color.brand}"
}
},
"disabled": {
"style": {
"opacity": 0.45
}
}
},
"transition": {
"duration": "{motion.fast}"
},
"onClick": "quit",
"children": [
{
"type": "Text",
"style": {
"color": "{color.text}",
"fontSize": "{text.md}"
},
"text": "Quit"
}
]
}
]
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "primary · secondary · secondary disabled"
}
]
}
}
}Las props vienen en dos tablas porque hay dos capas, y esa diferencia es el
modelo mental entero del producto: variant="primary" es una palabra que
escribes tú, y el theme la resuelve en un conjunto de estilos antes de exportar
nada. El juego que recibe el envelope no tiene ni idea
de que ahí hubiera una variant.
Props de autoría
Lo que escribes en @zabloo/react.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
| onClick | string | absent | Named action a la que el juego se suscribe. |
| children | ReactNode | absent | Contenido del botón. |
Además de estas, todo componente acepta las props base del nodo —id,
visible, disabled, layout, style, states, transition, autofocus,
clip— más variant, que el theme resuelve al exportar y que nunca aparece en
la IR.
Props de IR
Lo que llega al juego, cuando ya no queda capa de autoría.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
| onClick | string | absent | Named action, se dispara al activarse. |
| children | ZNode[] | [] | Hijos de flujo normales: una etiqueta, un icono, una fila con las dos cosas. |
Su contenido son sus hijos, colocados por el paso de flex de siempre. Un botón con un icono y un
texto es un Button que contiene un Row, no un Button con una prop icon — por eso no hay
límite a lo que un botón puede llevar dentro.
Comportamiento
- Estados
El jugador se pone encima y lleva
hover; lo mantiene pulsado y llevapressed; llega hasta él con el teclado o con el mando y llevafocused. Como botón elegido de un grupo"exclusive-select"—una pestaña— llevaselected. Ydisabled, propio o heredado de una sección que apaga un grupo entero de controles de golpe. El juego no oye nada de esto: un estado es el aspecto que tiene el botón, nunca un mensaje.- Focusable
Sí, para que el teclado y el mando puedan llegar hasta él.
disabledlo saca de la navegación, y eso deja suonClickfuera de alcance también: un control al que el jugador no puede llegar no puede disparar nada.- Activación
El jugador lo toca, pulsa Enter mientras lo tiene con el focus, o pulsa A en el mando → el botón se activa → se dispara
onClick. Una pulsación que termina fuera del control se cancela y no se envía nada: arrastrar el dedo fuera de un botón es como un jugador se echa atrás, y un mando que se desconecta a media pulsación se trata igual.- Actions
onClick, un solo nombre, se dispara después de que la activación termine. Dentro de un item deRepeatllega con un action context que dice desde qué elemento se ha disparado.
Degradación
En un SDK más antiguo la etiqueta se sigue viendo, pero pulsarla no hace nada.
Un Button que el SDK no conoce degrada a un Container: el contenido sobrevive, la pulsación desaparece. Button existe desde la v1, así que en la práctica esto solo afecta a las props añadidas después — pero la regla es la misma que sigue cualquier nodo, y es la razón de que un contenido más nuevo que el SDK que lo recibe sea un caso normal y no un crash.
Play
Play
Cómo oye esto el juego
El botón dispara el nombre; el juego decide qué significa. Ese es el contrato entero: el juego nunca se entera de qué dibujó el botón, el botón nunca se entera de qué hizo el juego con ello, y no hay que rehacer ninguna de las dos partes cuando la otra cambia. Por eso una pantalla se puede volver a publicar sin tocar la build que el jugador ya tiene.
using UnityEngine;
using Zabloo;
[RequireComponent(typeof(ZablooDocument))]
public sealed class MainMenu : MonoBehaviour
{
ZablooDocument _doc;
// Start, not OnEnable: it runs after ZablooDocument has built the view.
void Start()
{
_doc = GetComponent<ZablooDocument>();
_doc.OnAction += OnZablooAction;
}
void OnDestroy()
{
if (_doc != null) _doc.OnAction -= OnZablooAction;
}
void OnZablooAction(string action)
{
if (action == "play") StartGame();
if (action == "options") OpenOptions();
}
}Cuando la action se dispara desde dentro de un item de Repeat lleva un action context —el path, la key y el índice del item— para que el juego sepa qué fila se ha pulsado. El context es parte del formato y el renderer del navegador lo entrega; el OnAction de Unity es hoy Action<string> y entrega solo el nombre, así que un juego de Unity que necesite la fila la lee de su propio estado hasta que eso aterrice.
Composición
Como el contenido de un botón son sus hijos y nada más, todo el aspecto que puede tener un botón es una cuestión de layout en vez de una prop:
- Un icono delante de una etiqueta — usa un
Rowdentro del botón cuando el icono refuerza la palabra en vez de sustituirla. - Un botón ancho al lado de uno estrecho — pon
growen el que deba quedarse el espacio sobrante, cuando el de confirmar tenga que pesar más que su pareja. - Un botón que es solo un icono — un
Buttoncuyo único hijo es unImage, para una barra de herramientas donde el dibujo ya deja claro qué hace.

- hover — inactivo
- pressed — inactivo
- focused — inactivo
- selected — inactivo
- disabled — inactivo
Se lee del último frame dibujado
Parado: pulsa Ejecutar para dibujarlo en la GPU.
import { Button, Column, Image, Row, Text } from "@zabloo/react";
export default function ButtonComposition() {
return (
<Column
layout={{
grow: 1,
justify: "center",
align: "center",
gap: "{space.4}",
padding: "{space.6}",
}}
style={{ background: "{color.bg}" }}
>
{/* Icon + label: a Row inside the button. */}
<Button variant="primary" onClick="equip" layout={{ padding: "{space.3}" }}>
<Row layout={{ gap: "{space.2}", align: "center" }}>
<Image
src="icons/shield.png"
layout={{ width: 18, height: 18 }}
style={{ color: "{color.on-brand}" }}
/>
<Text style={{ color: "{color.on-brand}", fontSize: "{text.md}" }}>Equip</Text>
</Row>
</Button>
{/* A pair sharing a line: `grow` gives the primary the leftover space. */}
<Row layout={{ gap: "{space.2}", width: 320 }}>
<Button
variant="primary"
onClick="confirm"
layout={{ grow: 1, padding: "{space.3}", justify: "center", align: "center" }}
>
<Text style={{ color: "{color.on-brand}", fontSize: "{text.md}" }}>Confirm</Text>
</Button>
<Button variant="secondary" onClick="cancel" layout={{ padding: "{space.3}" }}>
<Text style={{ color: "{color.text}", fontSize: "{text.md}" }}>Cancel</Text>
</Button>
</Row>
{/* Icon only: the accessible name is the game's business, not the IR's. */}
<Row layout={{ gap: "{space.2}" }}>
<Button
variant="secondary"
onClick="favorite"
layout={{
padding: "{space.2}",
width: 40,
height: 40,
justify: "center",
align: "center",
}}
>
<Image
src="icons/star.png"
layout={{ width: 18, height: 18 }}
style={{ color: "{color.brand}" }}
/>
</Button>
<Button
variant="secondary"
onClick="charge"
layout={{
padding: "{space.2}",
width: 40,
height: 40,
justify: "center",
align: "center",
}}
>
<Image
src="icons/bolt.png"
layout={{ width: 18, height: 18 }}
style={{ color: "{color.gold}" }}
/>
</Button>
</Row>
</Column>
);
}{
"v": 1,
"tokens": {
"color.bg": "#0b0d13",
"color.surface": "#0e1016",
"color.raised": "#14141a",
"color.line": "#ffffff1f",
"color.line-strong": "#ffffff24",
"color.text": "#ffffff",
"color.muted": "#a1a1aa",
"color.faint": "#8a8a93",
"color.on-brand": "#ffffff",
"color.brand": "#8b5cf6",
"color.brand-strong": "#7c3aed",
"color.brand-hover": "#8b5cf6",
"color.brand-pressed": "#6d28d9",
"color.brand-soft": "#8b5cf61f",
"color.gold": "#fcd34d",
"color.slot": "#ffffff08",
"color.danger": "#f87171",
"radius.sm": 6,
"radius.md": 10,
"radius.lg": 14,
"radius.pill": 999,
"border.hairline": 1,
"border.focus": 2,
"space.1": 4,
"space.2": 8,
"space.3": 12,
"space.4": 16,
"space.5": 20,
"space.6": 24,
"text.xs": 11,
"text.sm": 13,
"text.md": 15,
"text.lg": 17,
"motion.fast": 120,
"motion.loop": 900
},
"views": {
"button-composition": {
"type": "Container",
"layout": {
"direction": "column",
"grow": 1,
"justify": "center",
"align": "center",
"gap": "{space.4}",
"padding": "{space.6}"
},
"style": {
"background": "{color.bg}"
},
"children": [
{
"type": "Button",
"layout": {
"padding": "{space.3}"
},
"style": {
"background": "{color.brand-strong}",
"radius": "{radius.md}",
"color": "{color.on-brand}"
},
"states": {
"hover": {
"style": {
"background": "{color.brand-hover}"
}
},
"pressed": {
"style": {
"background": "{color.brand-pressed}"
}
},
"focused": {
"style": {
"borderWidth": "{border.focus}",
"borderColor": "{color.brand}"
}
},
"disabled": {
"style": {
"opacity": 0.45
}
}
},
"transition": {
"duration": "{motion.fast}"
},
"onClick": "equip",
"children": [
{
"type": "Container",
"layout": {
"direction": "row",
"gap": "{space.2}",
"align": "center"
},
"children": [
{
"type": "Image",
"layout": {
"width": 18,
"height": 18
},
"style": {
"color": "{color.on-brand}"
},
"src": "asset:icons/shield.png"
},
{
"type": "Text",
"style": {
"color": "{color.on-brand}",
"fontSize": "{text.md}"
},
"text": "Equip"
}
]
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"gap": "{space.2}",
"width": 320
},
"children": [
{
"type": "Button",
"layout": {
"grow": 1,
"padding": "{space.3}",
"justify": "center",
"align": "center"
},
"style": {
"background": "{color.brand-strong}",
"radius": "{radius.md}",
"color": "{color.on-brand}"
},
"states": {
"hover": {
"style": {
"background": "{color.brand-hover}"
}
},
"pressed": {
"style": {
"background": "{color.brand-pressed}"
}
},
"focused": {
"style": {
"borderWidth": "{border.focus}",
"borderColor": "{color.brand}"
}
},
"disabled": {
"style": {
"opacity": 0.45
}
}
},
"transition": {
"duration": "{motion.fast}"
},
"onClick": "confirm",
"children": [
{
"type": "Text",
"style": {
"color": "{color.on-brand}",
"fontSize": "{text.md}"
},
"text": "Confirm"
}
]
},
{
"type": "Button",
"layout": {
"padding": "{space.3}"
},
"style": {
"background": "#00000000",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line-strong}",
"radius": "{radius.md}",
"color": "{color.text}"
},
"states": {
"hover": {
"style": {
"background": "{color.slot}"
}
},
"pressed": {
"style": {
"background": "{color.brand-soft}"
}
},
"focused": {
"style": {
"borderWidth": "{border.focus}",
"borderColor": "{color.brand}"
}
},
"disabled": {
"style": {
"opacity": 0.45
}
}
},
"transition": {
"duration": "{motion.fast}"
},
"onClick": "cancel",
"children": [
{
"type": "Text",
"style": {
"color": "{color.text}",
"fontSize": "{text.md}"
},
"text": "Cancel"
}
]
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"gap": "{space.2}"
},
"children": [
{
"type": "Button",
"layout": {
"padding": "{space.2}",
"width": 40,
"height": 40,
"justify": "center",
"align": "center"
},
"style": {
"background": "#00000000",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line-strong}",
"radius": "{radius.md}",
"color": "{color.text}"
},
"states": {
"hover": {
"style": {
"background": "{color.slot}"
}
},
"pressed": {
"style": {
"background": "{color.brand-soft}"
}
},
"focused": {
"style": {
"borderWidth": "{border.focus}",
"borderColor": "{color.brand}"
}
},
"disabled": {
"style": {
"opacity": 0.45
}
}
},
"transition": {
"duration": "{motion.fast}"
},
"onClick": "favorite",
"children": [
{
"type": "Image",
"layout": {
"width": 18,
"height": 18
},
"style": {
"color": "{color.brand}"
},
"src": "asset:icons/star.png"
}
]
},
{
"type": "Button",
"layout": {
"padding": "{space.2}",
"width": 40,
"height": 40,
"justify": "center",
"align": "center"
},
"style": {
"background": "#00000000",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line-strong}",
"radius": "{radius.md}",
"color": "{color.text}"
},
"states": {
"hover": {
"style": {
"background": "{color.slot}"
}
},
"pressed": {
"style": {
"background": "{color.brand-soft}"
}
},
"focused": {
"style": {
"borderWidth": "{border.focus}",
"borderColor": "{color.brand}"
}
},
"disabled": {
"style": {
"opacity": 0.45
}
}
},
"transition": {
"duration": "{motion.fast}"
},
"onClick": "charge",
"children": [
{
"type": "Image",
"layout": {
"width": 18,
"height": 18
},
"style": {
"color": "{color.gold}"
},
"src": "asset:icons/bolt.png"
}
]
}
]
}
]
}
},
"assets": {
"icons/bolt.png": {
"hash": "07ac2ea21678f0a21ac34d25f30670ee76b56b3ea8e7d1e98aea339505654adf",
"mime": "image/png",
"size": 599,
"width": 64,
"height": 64,
"data": "iVBORw0KGgoA… (599 bytes, base64)"
},
"icons/shield.png": {
"hash": "cf3786285ac535ea549843224e903d3c573b903faf4ad507ab572aca76895cdb",
"mime": "image/png",
"size": 573,
"width": 64,
"height": 64,
"data": "iVBORw0KGgoA… (573 bytes, base64)"
},
"icons/star.png": {
"hash": "02f69f8ed48938f7704d264cd732afec0762fd8fd654944876443c87ed3cb1bd",
"mime": "image/png",
"size": 689,
"width": 64,
"height": 64,
"data": "iVBORw0KGgoA… (689 bytes, base64)"
}
}
}Un botón es además el anchor natural de un popover: un Overlay anclado a
su id con trigger: "press" se abre con la misma pulsación que dispara
onClick. Abrirse es comportamiento; nunca sustituye a la action declarada.
Dentro de un item de Repeat, el mismo botón aparece una vez por elemento del
array enlazado y su action lleva consigo el item desde el que se disparó — así
es como onClick: "buy" puede decir qué fila se ha comprado sin que la UI
sepa nada de tiendas.