Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

Elementos

Scrollbar

Una scrollbar da a una vista de scroll o a una lista una barra con el aspecto propio de la app: una píldora delgada que se ensancha bajo el puntero, un carril en el tono de la marca, una barra junto a una cabecera fija o a lo largo de dos paneles. Definida una vez como el tema de la app, cada vista de scroll y cada lista dibuja una en lugar del indicador de la plataforma (Barras integradas); colocada por la app, nombra la vista de scroll que mueve y se sitúa donde la app la ponga. La barra gestiona su propio comportamiento: la longitud y la posición del thumb siguen al contenido, al viewport y al desplazamiento (también mientras una lista crece o la ventana cambia de tamaño); arrastrar el thumb desplaza el contenido, y pulsar sobre el carril lleva allí el centro del thumb; se muestra mientras el contenido se desplaza, mientras el puntero está sobre ella, o siempre, y aparece y desaparece con fundidos nativos. El aspecto y los tiempos son de la app: una Scrollbar no tiene colores, tamaños ni radios propios, y sus fundidos no tardan nada hasta que la app les da una duración.

el-scrollbar.tsx
el-scrollbar.tsxLive
import { mount, Scroll, signal, Text, View, type ScrollRef } from "nsh";
import { Scrollbar, type ScrollbarMode } from "nsh/base";
import { desktop, Panel, Segmented, size, Split, ui } from "./palette";

// A list of messages in a card whose own indicator is off, with the app's
// bar over its right edge, timed as gpui-component's: a 6-point pill in the
// palette's muted tone that widens to 8 under the pointer (`hover:`) and
// while dragged (`active:`), fading in over 300 ms and out over 500 ms, 2 s
// after the last scroll (`motion`); pointing at its hidden thumb in the
// `hover` mode slides it in. The inspector switches when it shows and
// follows the offset the list reports.
const MESSAGES = Array.from({ length: 60 }, (_, i) => ({
  from: ["Ada", "Grace", "Linus", "Ken", "Barbara"][i % 5],
  text: ["Shipped the build", "Review when you can", "Tests are green", "Moved the meeting", "Draft is ready"][i % 5],
  at: `${9 + Math.floor(i / 6)}:${String((i * 7) % 60).padStart(2, "0")}`,
}));

function App() {
  const mode = signal<ScrollbarMode>("scrolling");
  const offset = signal(0);
  let inbox: ScrollRef | undefined;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>scrollbar</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Scroll the list, drag the thumb or click the track" : "Scroll the list, or drag the thumb"}
      </Text>
      <Split
        gap={14}
        grow
        inspector={
          <Panel title="scrollbar">
            <Segmented
              label="mode"
              options={["scrolling", "hover", "always"] as const}
              value={mode()}
              onChange={(v) => mode.set(v)}
            />
            <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
              {`offset ${Math.round(offset())}`}
            </Text>
          </Panel>
        }
      >
        <View style={{ ...ui().card, flex: 1, minHeight: 320, borderRadius: 16, overflow: "hidden" }}>
          <Scroll
            ref={inbox}
            scrollbar={false}
            style={{ flex: 1 }}
            onScroll={(e) => offset.set(e.offset.y)}
          >
            {MESSAGES.map((m) => (
              <View
                class={desktop ? "flex-row gap-3 px-3 py-2.5" : "flex-row gap-3 px-4 py-3"}
                style={{ borderBottomWidth: 1, borderColor: ui().line }}
              >
                <View style={{ flex: 1, gap: 2 }}>
                  <Text style={{ fontSize: size.label, fontWeight: "500", color: ui().text }}>{m.from}</Text>
                  <Text style={{ fontSize: size.detail, color: ui().muted }}>{m.text}</Text>
                </View>
                <Text style={{ fontSize: size.detail, color: ui().muted, fontVariant: ["tabular-nums"] }}>{m.at}</Text>
              </View>
            ))}
          </Scroll>
          <Scrollbar
            scroll={inbox}
            mode={mode()}
            motion={{ idle: 2000, enter: 300, exit: 500, entrance: "fade", thumbHoverEntrance: "slideAndFade" }}
            accessibilityLabel="Messages"
            class="absolute inset-y-0 right-0 w-4 items-end p-1"
            thumbClass="w-1.5 rounded-full opacity-60 transition-all duration-300 hover:w-2 hover:opacity-90 active:w-2 active:opacity-100"
            thumbStyle={{ backgroundColor: ui().muted }}
          />
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{ScrollbarEntrance, ScrollbarMode, ScrollbarMotion, scrollbar, theme};
use nsh_core::prelude::*;

fn inbox(rows: impl IntoElement) -> impl IntoElement {
    let list = element_ref();
    // In over 300 ms and out over 500 ms, 2 s after the last scroll; pointing
    // at the hidden thumb slides it in from the edge.
    let mut motion = ScrollbarMotion::default();
    motion.enter = 300.0;
    motion.exit = 500.0;
    motion.thumb_hover_entrance = ScrollbarEntrance::SlideAndFade;
    view()
        .relative()
        .flex_1()
        // The scroll view's own indicator gives way to the app's bar.
        .child(scroll().element_ref(&list).flex_1().scrollbar(false).child(rows))
        .child(
            scrollbar()
                .scroll(list)
                .mode(ScrollbarMode::Hover)
                .motion(motion)
                .accessibility_label("Messages")
                // The track over the right edge; the thumb right-aligned in it.
                .absolute()
                .top_0()
                .bottom_0()
                .right_0()
                .w(px(16.0))
                .items_end()
                .p(px(4.0))
                .thumb_style(|s| {
                    s.w(px(6.0))
                        .rounded_full()
                        .bg(move || theme().muted_foreground)
                        .opacity(0.6)
                        .group_hover(|s| s.w(px(8.0)).opacity(0.9))
                }),
        )
}

Scrollbar proviene de nsh/base, el crate nsh-base de Rust. En Rust, scrollbar() crea una con un setter por cada prop (.scroll(..), .thumb_style(..)) y los métodos de estilo tipados para la propia barra; su scroll acepta un ElementRef (o un ScrollbarTarget).

#Barras integradas

Cada vista de scroll y cada lista pueden dibujar una Scrollbar en lugar del indicador de la plataforma, sin necesidad de colocar ninguna barra: Scrollbar.setTheme da el aspecto de la app a la barra de cada vista cuyo scrollbar sea true, una sola vez, allí donde la app arranca. Cada vista recibe su propia barra sobre su viewport, que permanece allí mientras el contenido se desplaza (en el hilo principal, como las vistas sticky) y se dibuja por encima del contenido; sigue, se desplaza y se desvanece como lo hace una Scrollbar colocada.

use nsh_base::{ScrollbarLook, ScrollbarMode, ScrollbarTheme, set_scrollbar_theme};

fn use_app_scrollbars() {
    let mut vertical = ScrollbarLook::default();
    vertical.class = Some("absolute inset-y-0 right-0 w-4 items-end p-1".into());
    vertical.thumb_class = Some("w-1.5 rounded-full bg-muted-foreground opacity-60 hover:w-2".into());
    let mut horizontal = ScrollbarLook::default();
    horizontal.class = Some("absolute inset-x-0 bottom-0 h-4 items-end p-1".into());
    horizontal.thumb_class = Some("h-1.5 rounded-full bg-muted-foreground opacity-60 hover:h-2".into());
    let mut theme = ScrollbarTheme::default();
    theme.mode = ScrollbarMode::Hover;
    theme.motion.enter = 300.0;
    theme.motion.exit = 500.0;
    theme.vertical = Some(vertical);
    theme.horizontal = Some(horizontal);
    set_scrollbar_theme(Some(theme));
}

Una ScrollbarTheme tiene las props de una barra que son iguales para todas las vistas, y el aspecto de cada eje:

Campo Tipo Valor por defecto Descripción
mode "scrolling" | "hover" | "always" "scrolling" Cuándo se muestran las barras: Cuándo se muestra.
motion ScrollbarMotion salta, idle 2000 Cómo se muestran y se ocultan: Movimiento.
minThumbLength number 48 La longitud mínima que alcanza un thumb.
vertical, horizontal ScrollbarLook ninguno El aspecto de las barras de las vistas verticales y de las horizontales: class y style para el carril, colocado en el viewport de la vista; thumbClass y thumbStyle; accessibilityLabel. Una vista cuyo eje no tiene aspecto conserva el indicador de la plataforma.

Un setTheme posterior vuelve a estilizar las barras, y Scrollbar.setTheme(null) devuelve a las vistas el indicador de la plataforma. Una vista puede recibir un tema propio como su prop scrollbar (<Scroll scrollbar={{ mode: "always", vertical: { … } }}>), haya definido o no la app uno; scrollbar={false} deja a una vista sin barra. Una lista maquetada en el viewport de su padre (viewport="parent") se desplaza con esa vista y con su barra. Un Input multilínea también recibe la barra de su aspecto vertical, sobre su texto, que desplaza igual que lo hace el indicador propio de la vista de texto.

#La vista de scroll que mueve

scroll nombra la vista de scroll o la lista que la barra sigue: su ref. La barra la encuentra una vez que está en pantalla, por lo que el ref puede ser asignado por un elemento posterior del mismo render; escribe la barra después de su vista de scroll (o guarda el ref en un signal). Desactiva el indicador propio de la vista de scroll con scrollbar={false}, y coloca la barra con class: sobre el borde de la vista de scroll dentro de una caja relative que rodee a ambas, o en cualquier otro lugar de la pantalla.

  • El thumb tiene la longitud de la proporción del contenido que muestra el viewport, a lo largo de la caja de contenido del carril (dentro de su padding), y no es más corto que minThumbLength. Su posición es cuánto se ha desplazado el contenido; mientras el contenido rebota más allá de un extremo, el thumb permanece en ese extremo.
  • Arrastrar el thumb desplaza el contenido tanto como se mueve el thumb. Pulsar sobre el carril lleva el centro del thumb bajo el puntero, y el arrastre continúa desde ahí.
  • Una barra vertical sigue a una vista de scroll vertical; axis="horizontal" crea una barra para una horizontal (colócala a lo largo del borde inferior). Una barra por eje.
  • Las listas funcionan igual: el thumb sigue la longitud total de la lista a medida que se miden y añaden filas.

#Cuándo se muestra

mode Se muestra
"scrolling" (por defecto) Mientras el contenido se desplaza, y hasta motion.idle después de que se detenga; el puntero sobre la barra mientras se muestra la mantiene visible.
"hover" Mientras el puntero está sobre la barra (donde se muestra, incluso estando oculta), y como lo hace "scrolling".
"always" Siempre.

En todos los modos se muestra mientras se arrastra su thumb, y permanece motion.idle tras el último scroll, arrastre o hover. Mientras el contenido cabe en el viewport, permanece oculta. Cuando está oculta, la barra está en opacity: 0 y deja pasar las pulsaciones a lo que hay debajo.

#Movimiento

motion define los tiempos de los fundidos de la barra, que se ejecutan de forma nativa, como lo hace ScrollbarMotion de gpui-base:

Campo Valor por defecto Descripción
idle 2000 Cuánto tiempo permanece la barra tras el último scroll, arrastre o hover, en milisegundos.
enter 0 Cuánto tarda en aparecer: su opacidad sube de forma lineal, un deslizamiento usa ease-out.
exit 0 Cuánto tarda en desvanecerse una vez terminado idle, con ease-in; se desliza fuera de su carril mientras se desvanece.
entrance "fade" Cómo entra: "fade" en su sitio, o "slideAndFade" desde el borde en el que está (la derecha, la parte inferior).
thumbHoverEntrance "fade" Cómo entra cuando el puntero sobre su thumb oculto la muestra ("hover").

Sin duraciones, la barra aparece y desaparece de golpe. Un fundido invertido a mitad de camino conserva su velocidad: se ejecuta durante la parte de su duración que aún queda por recorrer. Una barra "always" nunca se anima, ni tampoco ninguna mientras el usuario pida menos movimiento. El carril recorta lo que se desliza. El movimiento propio del thumb, como un ancho que crece en hover:, corresponde a su estilo (transition-all en thumbClass).

#Props

Prop Rust Tipo Valor por defecto Descripción
scroll .scroll(..) YNode ninguno La vista de scroll o lista que la barra sigue y mueve (su ref).
axis .axis(..) "horizontal" | "vertical" "vertical" El eje de la vista de scroll: el carril discurre a lo largo de él. Se lee cuando se crea la barra.
mode .mode(..) "scrolling" | "hover" | "always" "scrolling" Cuándo se muestra la barra.
motion .motion(..) ScrollbarMotion salta, idle 2000 Cómo se muestra y se oculta: Movimiento.
minThumbLength .min_thumb_length(..) number 48 La longitud mínima del thumb en un contenido largo, para que siga siendo fácil de agarrar.
thumbClass, thumbStyle .thumb_style(|s| ..) string, StyleProp<ViewStyle> ninguno El thumb.
accessibilityLabel .accessibility_label(..) string ninguno Lo que dicen los lectores de pantalla sobre la barra.
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno La barra: su carril, donde la app la coloca.

#Partes

Parte Dentro de Se estiliza con
El carril donde la app lo coloca class, style
El thumb la caja de contenido del carril thumbClass, thumbStyle

La barra dimensiona el thumb a lo largo del eje y lo mueve; su grosor y su posición a lo ancho del carril son cosa de la app (w-1.5 y items-end en el carril colocan un thumb de 6 puntos a la derecha). El padding del carril mantiene el thumb alejado de sus extremos y lados.

Estados que muestran las partes:

  • El carril: hover: bajo el puntero; es un group, por lo que el thumb toma group-hover: de él (un thumb que se ensancha cuando el puntero se acerca).
  • El thumb: hover: bajo el puntero, active: mientras se arrastra.

La visibilidad de la barra es su opacidad: aplica la transparencia a los colores del fondo del carril o del thumb. Un thumb que se ensancha usa transition-all (que nunca retrasa la posición del thumb, que la barra establece al margen de los estilos del thumb), y motion-reduce: lo mantiene quieto para las personas que piden menos movimiento.

#Accesibilidad

  • La vista de scroll sigue siendo lo que desplazan los lectores de pantalla: la mueven con sus propios gestos y leen su contenido.
  • La barra se etiqueta mediante accessibilityLabel para los lectores de pantalla que llegan a ella.

Consulta Accesibilidad para etiquetas, roles y acciones en general.

#Notas sobre plataformas

  • Cada plataforma dibuja lo que la app estiliza, y el thumb sigue el desplazamiento que cada plataforma informa para la vista de scroll. Los arrastres del thumb y las pulsaciones sobre el carril funcionan con ratón, lápiz o dedo.
  • "hover" sigue a un puntero de ratón o de touchpad (los escritorios y la web); en una pantalla táctil, la barra se muestra mientras el contenido se desplaza, y su carril oculto deja pasar los toques.