Slider
Un número que el jugador fija arrastrando. El fill y el tirador se colocan a partir del propio valor — la única disposición que el paso de layout normal no sabe producir.
primitivedesde v1focusable
Un Slider es un track que el jugador arrastra para fijar un número. Usas uno
cuando un valor es continuo y aproximado —un volumen, la sensibilidad del ratón,
un brillo— y notarlo moverse importa más que la cifra exacta. Piensa en un panel
de ajustes de audio: la fila Master volume es un slider que escribe en un
número del que es dueño el juego, con un Text enlazado al mismo path
enseñándolo. Es el espejo de una
ProgressBar — la misma geometría, pero
un slider lo fija el jugador y una barra la fija el juego.

- 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 { Column, Row, Slider, Text } from "@zabloo/react";
const TRACK = { background: "{color.slot}", radius: "{radius.pill}" } as const;
const FILL = { background: "{color.brand}", radius: "{radius.pill}" } as const;
const THUMB = { background: "{color.text}", radius: "{radius.pill}" } as const;
export default function SliderRange() {
return (
<Column
layout={{ grow: 1, justify: "center", align: "center", padding: "{space.6}" }}
style={{ background: "{color.bg}" }}
>
<Column
id="panel"
layout={{ width: 420, padding: "{space.5}", gap: "{space.5}", align: "stretch" }}
style={{
background: "{color.surface}",
radius: "{radius.lg}",
borderWidth: "{border.hairline}",
borderColor: "{color.line}",
}}
>
<Column layout={{ gap: "{space.2}", align: "stretch" }}>
<Row layout={{ justify: "space-between", align: "center" }}>
<Text style={{ color: "{color.muted}", fontSize: "{text.sm}" }}>Master volume</Text>
{/* Same path as the slider. The label follows the drag because both
read the game's data, not each other. */}
<Text bind="settings.volume" style={{ color: "{color.text}", fontSize: "{text.sm}" }} />
</Row>
{/* Continuous: the two hooks split the two questions a game asks about
a drag — preview it live, apply it once the player lets go. */}
<Slider
id="volume"
value={{ bind: "settings.volume" }}
onChange="volume-preview"
onCommit="volume-apply"
length={380}
style={TRACK}
fill={FILL}
thumb={THUMB}
/>
</Column>
<Column layout={{ gap: "{space.2}", align: "stretch" }}>
<Row layout={{ justify: "space-between", align: "center" }}>
<Text style={{ color: "{color.muted}", fontSize: "{text.sm}" }}>Brightness</Text>
<Text
bind="settings.brightness"
style={{ color: "{color.text}", fontSize: "{text.sm}" }}
/>
</Row>
{/* Quantized to min + k · step. `max` is always a valid stop, even when
the range is not a whole number of steps. */}
<Slider
id="brightness"
value={{ bind: "settings.brightness" }}
min={0}
max={100}
step={10}
onCommit="brightness-apply"
length={380}
style={TRACK}
fill={{ background: "{color.gold}", radius: "{radius.pill}" }}
thumb={THUMB}
/>
</Column>
</Column>
</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": {
"slider-range": {
"type": "Container",
"layout": {
"direction": "column",
"grow": 1,
"justify": "center",
"align": "center",
"padding": "{space.6}"
},
"style": {
"background": "{color.bg}"
},
"children": [
{
"type": "Container",
"id": "panel",
"layout": {
"direction": "column",
"width": 420,
"padding": "{space.5}",
"gap": "{space.5}",
"align": "stretch"
},
"style": {
"background": "{color.surface}",
"radius": "{radius.lg}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"children": [
{
"type": "Container",
"layout": {
"direction": "column",
"gap": "{space.2}",
"align": "stretch"
},
"children": [
{
"type": "Container",
"layout": {
"direction": "row",
"justify": "space-between",
"align": "center"
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.muted}",
"fontSize": "{text.sm}"
},
"text": "Master volume"
},
{
"type": "Text",
"style": {
"color": "{color.text}",
"fontSize": "{text.sm}"
},
"text": {
"bind": "settings.volume"
}
}
]
},
{
"type": "Slider",
"id": "volume",
"layout": {
"width": 380,
"height": 6
},
"style": {
"radius": "{radius.pill}",
"background": "{color.slot}"
},
"value": {
"bind": "settings.volume"
},
"onChange": "volume-preview",
"onCommit": "volume-apply",
"children": [
{
"type": "Container",
"layout": {
"height": 6
},
"style": {
"radius": "{radius.pill}",
"background": "{color.brand}"
}
},
{
"type": "Container",
"layout": {
"width": 18,
"height": 18
},
"style": {
"radius": "{radius.pill}",
"background": "{color.text}"
}
}
]
}
]
},
{
"type": "Container",
"layout": {
"direction": "column",
"gap": "{space.2}",
"align": "stretch"
},
"children": [
{
"type": "Container",
"layout": {
"direction": "row",
"justify": "space-between",
"align": "center"
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.muted}",
"fontSize": "{text.sm}"
},
"text": "Brightness"
},
{
"type": "Text",
"style": {
"color": "{color.text}",
"fontSize": "{text.sm}"
},
"text": {
"bind": "settings.brightness"
}
}
]
},
{
"type": "Slider",
"id": "brightness",
"layout": {
"width": 380,
"height": 6
},
"style": {
"radius": "{radius.pill}",
"background": "{color.slot}"
},
"value": {
"bind": "settings.brightness"
},
"min": 0,
"max": 100,
"step": 10,
"onCommit": "brightness-apply",
"children": [
{
"type": "Container",
"layout": {
"height": 6
},
"style": {
"radius": "{radius.pill}",
"background": "{color.gold}"
}
},
{
"type": "Container",
"layout": {
"width": 18,
"height": 18
},
"style": {
"radius": "{radius.pill}",
"background": "{color.text}"
}
}
]
}
]
}
]
}
]
}
}
}Las dos tablas de abajo se diferencian de una manera que vale la pena nombrar,
porque es lo que la capa de componentes está haciendo por ti: length,
thickness y thumbSize son números cómodos de escribir y ya no existen cuando
se publica nada — se resuelven en el layout del nodo y en los tamaños de los
dos slots. Un layout explícito les sigue ganando.
Props de autoría
<Slider> emite este nodo con los dos slots ya construidos. No hay una
exportación en crudo que te los deje a ti: las posiciones son una convención, y
el componente es el único sitio donde está escrita.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
| value | Bindable<number> | min | Valor actual, o un binding de lectura y escritura. |
| min / max | number | 0 / 1 | Los extremos del rango — el intervalo unidad donde vive un volumen o una proporción. |
| step | number | absent | Paso de cuantización. Ausente = continuo. |
| axis | "horizontal" | "vertical" | "horizontal" | Orientación del track. El vertical va de abajo arriba, como un fader. |
| onChange / onCommit | string | absent | El hook en vivo y el de cuando se asienta. |
| length / thickness / thumbSize | number | 200 / 6 / 18 | Largo y grosor del track, y tamaño del tirador, en px. |
| fill / thumb | Style | absent | La parte llena del track, y el tirador que va sobre él. |
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 |
|---|---|---|---|
| value | Bindable<number> | min | Valor actual, o un binding de lectura y escritura. |
| min / max | number | 0 / 1 | Los extremos del rango. |
| step | number | absent | Paso de cuantización desde min. Ausente o <= 0 = continuo. |
| axis | "horizontal" | "vertical" | "horizontal" | Orientación del track. |
| onChange | string | absent | Named action que se dispara en cada cambio de valor. |
| onCommit | string | absent | Named action que se dispara cuando termina un gesto. |
| children | ZNode[] | [] | Dos slots posicionales: primero el fill, después el thumb. |
El nodo es el track
Su style pinta el raíl con el pintado implícito de siempre —ningún comando de
dibujo nuevo, ningún tercer slot— y acepta exactamente dos hijos posicionales
que el SDK dispone a partir del valor en vez de colocarlos en el flujo:
| Slot | Qué es | Cómo lo coloca el SDK |
|---|---|---|
children[0] | el fill | desde el principio del track hasta la fracción del valor |
children[1] | el thumb | su propio tamaño, centrado en la posición del valor |
El recorrido del thumb va metido hacia dentro la mitad de su propio tamaño,
así que nunca pinta fuera del rect del nodo: el invariante de la border-box se
cumple y el hit-testing sobre los rects del layout sigue siendo honesto. Y un
Slider se mide como una hoja: los slots no aportan nada a su tamaño, y por
eso un thumb de 18 px no puede definir un track de 200 px.
El valor
value es o un número inicial literal o un binding de
lectura y escritura: el SDK escribe cada valor nuevo en su almacén de datos
y avisa al juego. Se acota a [min, max] y, si hay step, se cuantiza a
min + k · step.
max siempre es una parada válida aunque el rango no sea un número entero de
pasos: 0..1 de 0.3 en 0.3 para en 0.9 y luego en 1. El jugador ve el
final del track, así que dejarlo inalcanzable se leería como un control
atascado; el precio es un último paso corto, que es la sorpresa más pequeña de
las dos. Un rango inservible (max <= min, NaN, un step negativo) colapsa a un
slider fijo en vez de a un error.
onChange se dispara en cada cambio, venga de donde venga: es el hook en vivo al que se
engancha una previsualización de volumen. onCommit se dispara cuando el gesto termina
(se suelta el puntero, se levanta la tecla): el valor en el que el jugador se ha quedado, donde
cuelga el ajuste caro de aplicar en vez de que el juego haga debounce por su cuenta. Un value
enlazado se escribe en cada cambio, se declare el hook que se declare.
Comportamiento
- Estados
hover,pressedyfocused, másdisabled— propio o heredado.pressedes el arrastre: dura lo que el puntero mantenga el control, y las flechas que mueven el valor de paso en paso no lo levantan nunca, porque no hay nada que activar. Un gesto en marcha cuando el juego deshabilita el control se cancela, no se hace commit: el valor nunca llegó a asentarse.- Focusable
Sí, salvo que esté
disabled. Se queda las flechas de su propio eje y no las devuelve; las del eje contrario siguen navegando. Un slider continuo toma prestado un paso del 5% de su rango para el teclado, así que las flechas funcionan sin obligarte a declarar unstepque por lo demás no quieres.- Movimiento
Con una
transition, el SDK desliza hasta un valor que ha empujado el juego y salta al que el jugador está arrastrando. Un control no puede ir nunca por detrás del dedo.- Actions
El jugador arrastra → el SDK acota y cuantiza el número, lo escribe en los datos y mueve el fill y el thumb → se dispara
onChange. Cuando el jugador suelta se dispara ademásonCommit: dos named actions, un solo gesto. En el otro sentido,SetValue(id, value)recorre esa secuencia entera en una sola llamada.
Degradación
En un SDK más antiguo se ven el raíl, el fill y el tirador, pero el tirador se queda donde lo haya puesto el layout y arrastrarlo no hace nada.
Como Container: el raíl, con un fill sin tamaño y un thumb colocado en el flujo a su lado. Inerte. Los dos slots sobreviven porque son nodos normales — lo que se pierde es la disposición que dictaba el valor, que es justo lo único que este tipo existe para calcular.
Cómo oye esto el juego
El binding lleva el número; la action solo dice cuándo se ha movido. Ninguna action de la v1 lleva un valor, así que la cifra en sí llega por el canal de datos, a través del binding de lectura y escritura, que es la pata que existe exactamente para eso. Lo que añaden las dos actions es el momento, y separarlas es lo importante: previsualizar un volumen en cada frame de un arrastre es barato, y volver a aplicar un preset de gráficos no lo es.
using UnityEngine;
using Zabloo;
[RequireComponent(typeof(ZablooDocument))]
public sealed class AudioOptions : MonoBehaviour
{
ZablooDocument _doc;
void Start()
{
_doc = GetComponent<ZablooDocument>();
_doc.OnAction += OnZablooAction;
// The slider reads the game's own value through its binding.
_doc.SetData("settings.volume", AudioListener.volume);
}
void OnDestroy()
{
if (_doc != null) _doc.OnAction -= OnZablooAction;
}
void OnZablooAction(string action)
{
// Follow the drag with something cheap...
if (action == "volume-preview") PlayTickSound();
// ...and do the expensive part once, when the player lets go.
if (action == "volume-apply") SaveAudioSettings();
}
}Composición
El componente construye el fill y el thumb; tú los estilizas. Todo lo demás es el layout normal que le darías a cualquier nodo.
- Continuo, con los dos hooks — para un ajuste que puedas previsualizar mientras se arrastra y quieras aplicar una sola vez, al soltar.
- Cuantizado y vertical — usa
stepyaxispara el fader de un mezclador, donde las muescas son parte del control. - Estilizado — el nodo es el raíl, así que su propio style pinta la ranura;
usa
fillythumbpara las dos piezas que mueve el valor. - Ninguna de las anteriores — un número que el jugador solo lee es una
ProgressBar, no unSlidercon el input apagado.
// Continuous, both hooks, bound to the game's data.
<Slider value={{ bind: "settings.volume" }} onChange="volume-preview" onCommit="volume-apply" />
// Quantized and vertical: a fader, running bottom-to-top.
<Slider min={0} max={100} step={10} axis="vertical" length={120} />
// Styled: the node is the rail, so its own style paints the groove.
<Slider
value={{ bind: "settings.volume" }}
style={{ background: "{color.slot}", radius: "{radius.pill}" }}
fill={{ background: "{color.brand}", radius: "{radius.pill}" }}
thumb={{ background: "{color.text}", radius: "{radius.pill}" }}
/>
// A read-only number is not a Slider. A fraction of the parent is a ProgressBar.
<ProgressBar value={{ bind: "player.hp" }} />