Элементы
Scrollbar
Scrollbar даёт области прокрутки или списку полосу в собственном стиле приложения: тонкую «таблетку», которая расширяется под указателем, дорожку фирменного тона, полосу рядом с фиксированным заголовком или вдоль двух панелей. Если задать её один раз как тему приложения, каждая область прокрутки и каждый список рисуют такую полосу вместо индикатора платформы (Встроенные полосы). Если приложение размещает полосу само, она указывает, какой областью прокрутки управляет, и находится там, где её поставит приложение. Полоса сама отвечает за своё поведение: длина и положение ползунка следуют за содержимым, областью просмотра и смещением (в том числе пока список растёт или окно меняет размер). Перетаскивание ползунка прокручивает содержимое, а нажатие на дорожку переносит середину ползунка в это место. Полоса показывается, пока содержимое прокручивается, пока над ней находится указатель или всегда, а плавное появление и исчезновение выполняются нативно. Внешний вид и тайминги задаёт приложение: у Scrollbar нет собственных цветов, размеров и радиусов, а её затухания не занимают времени, пока приложение его не задаст.
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))
}),
)
}import { mount, Scroll, Text, View, type YNode } from "nsh";
import { Scrollbar } from "nsh/base";
function App() {
let list: YNode | undefined;
return (
<View class="relative flex-1">
<Scroll ref={list} scrollbar={false} class="flex-1">
{Array.from({ length: 60 }, (_, i) => (
<Text class="px-4 py-3">{`Message ${i + 1}`}</Text>
))}
</Scroll>
<Scrollbar
scroll={list}
mode="hover"
motion={{ enter: 300, exit: 500, thumbHoverEntrance: "slideAndFade" }}
accessibilityLabel="Messages"
class="absolute inset-y-0 right-0 w-4 items-end p-1"
thumbClass="w-1.5 rounded-full bg-muted-foreground opacity-60 transition-all duration-300 hover:w-2 hover:opacity-90 active:w-2 active:opacity-100"
/>
</View>
);
}
mount(App);Scrollbar поставляется из nsh/base, крейта nsh-base для Rust. В Rust scrollbar() создаёт полосу с сеттером для каждого свойства (.scroll(..), .thumb_style(..)) и типизированными методами стиля для самой полосы; её scroll принимает ElementRef (или ScrollbarTarget).
#Встроенные полосы
Каждая область прокрутки и каждый список могут рисовать Scrollbar вместо индикатора платформы, и размещать полосу не нужно: Scrollbar.setTheme один раз, там, где запускается приложение, задаёт внешний вид полосы каждого представления, у которого scrollbar равно true. Каждое представление получает собственную полосу поверх своей области просмотра; она остаётся на месте, пока содержимое прокручивается (в основном потоке, как и закреплённые представления), и рисуется над содержимым. Она следует за содержимым, прокручивается и затухает так же, как размещённая Scrollbar.
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));
}import { Scrollbar } from "nsh/base";
Scrollbar.setTheme({
mode: "hover",
motion: { enter: 300, exit: 500 },
vertical: {
class: "absolute inset-y-0 right-0 w-4 items-end p-1",
thumbClass: "w-1.5 rounded-full bg-muted-foreground opacity-60 transition-all duration-300 hover:w-2",
},
horizontal: {
class: "absolute inset-x-0 bottom-0 h-4 items-end p-1",
thumbClass: "h-1.5 rounded-full bg-muted-foreground opacity-60 transition-all duration-300 hover:h-2",
},
});ScrollbarTheme содержит свойства полосы, одинаковые для всех представлений, и внешний вид каждой оси:
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
mode | "scrolling" | "hover" | "always" | "scrolling" | Когда полосы показываются: Когда она показывается. |
motion | ScrollbarMotion | мгновенно, idle 2000 | Как они появляются и скрываются: Анимация. |
minThumbLength | number | 48 | Минимальная длина ползунка. |
vertical, horizontal | ScrollbarLook | нет | Внешний вид полос вертикальных и горизонтальных представлений: class и style для дорожки, размещённой в области просмотра представления; thumbClass и thumbStyle; accessibilityLabel. Представление, у оси которого нет внешнего вида, сохраняет индикатор платформы. |
Более поздний setTheme меняет стиль полос, а Scrollbar.setTheme(null) возвращает представлениям индикатор платформы. Одно представление может получить собственную тему через свойство scrollbar (<Scroll scrollbar={{ mode: "always", vertical: { … } }}>), независимо от того, задало ли приложение общую тему; scrollbar={false} оставляет представление без полосы. Список, размещённый в области просмотра родителя (viewport="parent"), прокручивается вместе с этим представлением и его полосой. Многострочный Input тоже получает полосу своего вертикального вида поверх текста, который она прокручивает так же, как собственный индикатор текстового представления.
#Прокручиваемое представление, которым она управляет
scroll указывает область прокрутки или список, за которыми следует полоса: её ref. Полоса находит его, когда оно оказывается на экране, поэтому ref может быть задан элементом, расположенным позже в том же рендере; пишите полосу после её области прокрутки (или храните ref в сигнале). Отключите собственный индикатор области прокрутки через scrollbar={false} и разместите полосу через class: поверх края области прокрутки в блоке relative, охватывающем обе, или в любом другом месте экрана.
- Длина ползунка соответствует доле содержимого, которую показывает область просмотра, вдоль контентного блока дорожки (внутри её отступов), и не меньше
minThumbLength. Его положение показывает, насколько прокручено содержимое; пока содержимое отскакивает за пределы края, ползунок остаётся у этого края. - Перетаскивание ползунка прокручивает содержимое на столько же, на сколько сдвигается ползунок. Нажатие на дорожку переносит середину ползунка под указатель, и перетаскивание продолжается оттуда.
- Вертикальная полоса следует за вертикальной областью прокрутки;
axis="horizontal"создаёт полосу для горизонтальной (разместите её вдоль нижнего края). Одна полоса на ось. - Списки работают так же: ползунок следует за полной длиной списка по мере измерения и добавления строк.
#Когда она показывается
mode | Показывается |
|---|---|
"scrolling" (по умолчанию) | Пока содержимое прокручивается, и до motion.idle после остановки; указатель на полосе, пока она показана, удерживает её. |
"hover" | Пока указатель находится над полосой (в том месте, где она показывается, даже когда скрыта), а также как в режиме "scrolling". |
"always" | Всегда. |
В любом режиме полоса показывается, пока ползунок перетаскивается, и остаётся motion.idle после последней прокрутки, перетаскивания или наведения. Пока содержимое помещается в области просмотра, полоса остаётся скрытой. В скрытом состоянии у полосы opacity: 0, и она пропускает нажатия к тому, что находится под ней.
#Анимация
motion задаёт время затуханий полосы, которые выполняются нативно, как ScrollbarMotion у gpui-base:
| Поле | По умолчанию | Описание |
|---|---|---|
idle | 2000 | Сколько полоса остаётся после последней прокрутки, перетаскивания или наведения, в миллисекундах. |
enter | 0 | Сколько времени занимает появление: непрозрачность растёт линейно, сдвиг замедляется к концу. |
exit | 0 | Сколько времени занимает затухание после окончания idle, с ускорением к концу; при затухании полоса выезжает за пределы своей дорожки. |
entrance | "fade" | Как она появляется: "fade" на месте или "slideAndFade" от края, у которого находится (справа, снизу). |
thumbHoverEntrance | "fade" | Как она появляется, когда указатель на скрытом ползунке показывает её ("hover"). |
Без длительностей полоса появляется и исчезает мгновенно. Затухание, развёрнутое на полпути, сохраняет скорость: оно длится столько, сколько составляет оставшаяся доля его длительности. Полоса "always" никогда не анимируется, как и любая полоса, пока пользователь просит уменьшить движение. Дорожка обрезает то, что сдвигается. Собственная анимация ползунка, например ширина, которая растёт при hover:, относится к его стилю (transition-all у thumbClass).
#Свойства
| Свойство | Rust | Тип | По умолчанию | Описание |
|---|---|---|---|---|
scroll | .scroll(..) | YNode | нет | Область прокрутки или список, за которыми следует полоса и которыми она управляет (их ref). |
axis | .axis(..) | "horizontal" | "vertical" | "vertical" | Ось области прокрутки: дорожка идёт вдоль неё. Читается при создании полосы. |
mode | .mode(..) | "scrolling" | "hover" | "always" | "scrolling" | Когда полоса показывается. |
motion | .motion(..) | ScrollbarMotion | мгновенно, idle 2000 | Как она появляется и скрывается: Анимация. |
minThumbLength | .min_thumb_length(..) | number | 48 | Минимальная длина ползунка при длинном содержимом, чтобы его было удобно захватить. |
thumbClass, thumbStyle | .thumb_style(|s| ..) | string, StyleProp<ViewStyle> | нет | Ползунок. |
accessibilityLabel | .accessibility_label(..) | string | нет | Что программы чтения с экрана произносят о полосе. |
class, style | методы стиля | string, StyleProp<ViewStyle> | нет | Полоса: её дорожка, там, где её размещает приложение. |
#Части
| Часть | Находится внутри | Стилизуется с помощью |
|---|---|---|
| Дорожка | там, где её размещает приложение | class, style |
| Ползунок | контентного блока дорожки | thumbClass, thumbStyle |
Полоса задаёт размер ползунка вдоль оси и перемещает его; его толщина и положение поперёк дорожки определяются приложением (w-1.5 и items-end у дорожки помещают 6-пунктовый ползунок справа). Отступы дорожки не дают ползунку касаться её концов и сторон.
Состояния, которые показывают части:
- Дорожка:
hover:под указателем; она являетсяgroup, поэтому ползунок получает от неёgroup-hover:(ползунок, который расширяется, когда указатель приближается). - Ползунок:
hover:под указателем,active:пока его перетаскивают.
Видимость полосы определяется её непрозрачностью: задавайте прозрачность в цветах фона дорожки или ползунка. Ползунок, который расширяется, использует transition-all (она никогда не задерживает положение ползунка, которое полоса задаёт отдельно от его стилей), а motion-reduce: оставляет его неподвижным для людей, которые просят уменьшить движение.
#Доступность
- Область прокрутки остаётся тем, что прокручивают программы чтения с экрана: они двигают её собственными жестами и читают её содержимое.
- Полоса получает метку из
accessibilityLabelдля программ чтения с экрана, которые до неё добираются.
Общие сведения о метках, ролях и действиях см. в разделе Доступность.
#Заметки по платформам
- Каждая платформа рисует то, что задано стилями приложения, а ползунок следует за смещением, которое платформа сообщает для области прокрутки. Перетаскивание ползунка и нажатия на дорожку работают с мышью, пером или пальцем.
"hover"следует за указателем мыши или тачпада (на десктопах и в вебе); на сенсорном экране полоса показывается, пока содержимое прокручивается, а её скрытая дорожка пропускает касания.