Elements
HoverCard
A hover card previews what its trigger points at without leaving the page: the profile behind a mention, the page
behind a link, the quote behind a ticker. On the desktops and in desktop browsers it opens once the pointer has rested
on the trigger for openDelay (600 ms), or once the keyboard focus comes to the trigger. It stays open while the
pointer moves from the trigger onto the card and back, so links and buttons in the card can be used, and closes
closeDelay (300 ms) after the pointer has left both. Escape and a press outside close it at once. On phones and
tablets a press on the trigger opens and closes it, and a press outside closes it.
import { type JSX, mount, signal, Text, View } from "nsh";
import { HoverCard, type OpenChangeReason } from "nsh/base";
import { desktop, focusRing, Inspector, Log, PopIn, type PopupSide, Segmented, Stage, tint, ui, wide } from "./palette";
type Placement = PopupSide;
/** A card's surface, the look the app gives it; `PopIn` brings it in from the trigger's side. */
const surface = (width: number) => ({ ...ui().card, width, borderRadius: 12, padding: 14, gap: 10 });
/** The popup's box fades on its way out (its ending class, kept while the transition runs). */
const exit = { popupClass: "transition-opacity duration-150 ease-in motion-reduce:transition-none", popupEndingClass: "opacity-0" };
function App() {
const openDelay = signal(600);
const closeDelay = signal(300);
const placement = signal<Placement>("bottom");
const events = signal<string[]>([]);
const following = signal(false);
const price = signal(227.52);
let timer: ReturnType<typeof setInterval> | undefined;
const log = (name: string) => ({ open, reason }: { open: boolean; reason: OpenChangeReason }) =>
events.set([`${name} ${open ? "opened" : "closed"} · ${reason}`, ...events()].slice(0, 6));
const shared = () => ({ ...exit, openDelay: openDelay(), closeDelay: closeDelay(), placement: placement(), offset: 8, margin: 12 });
// Running text in a wrapping row: one piece per word, so a line breaks
// between words, and the row's gap is the space between them.
const words = (s: string) => s.split(" ").map((w) => <Text style={{ fontSize: 15, color: ui().text }}>{w}</Text>);
// A mention: the person's card, with a button that works because the
// pointer can move into the card.
const mention = () => (
<HoverCard {...shared()} onOpenChange={log("@ada")} trigger={
<View role="link" onPress={() => events.set(["onPress /people/ada", ...events()].slice(0, 6))} focusStyle={focusRing(1)} style={{ borderRadius: 4, borderWidth: 1, borderColor: "transparent", paddingHorizontal: 2, marginHorizontal: -3 }} hoverStyle={{ backgroundColor: tint(ui().accent, 0.14) }}>
<Text style={{ fontSize: 15, fontWeight: "600", color: ui().accent }}>@ada</Text>
</View>
}>
<PopIn side={placement()} style={surface(260)}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
<View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: tint(ui().accent, 0.18), alignItems: "center", justifyContent: "center" }}>
<Text style={{ fontSize: 15, fontWeight: "600", color: ui().accent }}>AL</Text>
</View>
<View style={{ flex: 1, gap: 2 }}>
<Text style={{ fontSize: 15, fontWeight: "600", color: ui().text }}>Ada Lovelace</Text>
<Text style={{ fontSize: 12, color: ui().muted }}>Analyst · Engines</Text>
</View>
</View>
<Text style={{ fontSize: 13, lineHeight: 18, color: ui().text }}>Writes the first programs for the Analytical Engine.</Text>
<View
onPress={() => following.set(!following())}
style={{ height: 30, borderRadius: 8, alignItems: "center", justifyContent: "center", ...(following() ? ui().raised : ui().accentFill), transitionProperty: "background-color", transitionDuration: 150 }}
>
<Text style={{ fontSize: 13, fontWeight: "500", color: following() ? ui().text : ui().onAccent }}>{following() ? "Following" : "Follow"}</Text>
</View>
</PopIn>
</HoverCard>
);
// A link with a preview of the page behind it.
const preview = () => (
<HoverCard {...shared()} onOpenChange={log("taffy")} trigger={
<View role="link" onPress={() => events.set(["onPress github.com/DioxusLabs/taffy", ...events()].slice(0, 6))} focusStyle={focusRing(1)} style={{ borderBottomWidth: 1, borderBottomColor: tint(ui().accent, 0.4) }}>
<Text style={{ fontSize: 15, color: ui().accent }}>Taffy</Text>
</View>
}>
<PopIn side={placement()} style={{ ...surface(280), padding: 0, gap: 0, overflow: "hidden" }}>
<View style={{ height: 64, backgroundImage: "linear-gradient(135deg, #3b82f6, #8b5cf6)" }} />
<View style={{ padding: 12, gap: 4 }}>
<Text style={{ fontSize: 11, color: ui().faint }}>github.com</Text>
<Text style={{ fontSize: 14, fontWeight: "600", color: ui().text }}>taffy: flexbox, grid and block layout</Text>
<Text style={{ fontSize: 12, color: ui().muted }}>A high-performance UI layout library written in Rust.</Text>
</View>
</PopIn>
</HoverCard>
);
// A ticker whose quote ticks while its card is open.
const ticker = () => (
<HoverCard
{...shared()}
onOpenChange={log("$AAPL")}
onOpen={() => (timer = setInterval(() => price.set(Math.round((price() + (Math.random() - 0.48) * 0.6) * 100) / 100), 900))}
onClose={() => clearInterval(timer)}
trigger={
<View focusable focusStyle={focusRing(1)} style={{ paddingHorizontal: 8, height: 26, borderRadius: 13, justifyContent: "center", borderWidth: 1, borderColor: tint(ui().up, 0.3), backgroundColor: tint(ui().up, 0.1) }}>
<Text style={{ fontSize: 13, fontWeight: "600", color: ui().up }}>$AAPL ▲</Text>
</View>
}
>
<PopIn side={placement()} style={surface(220)}>
<Text style={{ fontSize: 12, color: ui().muted }}>Apple Inc. · live</Text>
<Text style={{ fontSize: 26, fontWeight: "600", color: ui().text, fontVariant: ["tabular-nums"] }}>{price().toFixed(2)}</Text>
<Text style={{ fontSize: 13, color: price() >= 224.1 ? ui().up : ui().down, fontVariant: ["tabular-nums"] }}>
{price() >= 224.1 ? "+" : ""}
{(price() - 224.1).toFixed(2)} today
</Text>
</PopIn>
</HoverCard>
);
const stage = (): JSX.Element => (
<View style={{ gap: 18, alignItems: "flex-start" }}>
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", columnGap: 4, rowGap: 6 }}>
{words("Ask")}
{mention()}
{words("about the layout engine,")}
{/* The period stays with the link. */}
<View style={{ flexDirection: "row", alignItems: "center" }}>
{preview()}
{words(".")}
</View>
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
{words("Today:")}
{ticker()}
</View>
</View>
);
const controls = () => (
<>
<Segmented label="openDelay" options={[0, 300, 600, 1200]} value={openDelay()} onChange={(v) => openDelay.set(v)} />
<Segmented label="closeDelay" options={[0, 300, 1000]} value={closeDelay()} onChange={(v) => closeDelay.set(v)} />
<Segmented label="placement" options={["top", "bottom", "left", "right"] as Placement[]} value={placement()} onChange={(v) => placement.set(v)} />
</>
);
const changes = () => <Log title="onOpenChange({ open, reason })" lines={events()} minHeight={110} />;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>hover card</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Rest the pointer on a mention, a link or a ticker, then move into the card" : "Tap a mention, a link or a ticker; tap outside to close"}
</Text>
{() =>
wide() ? (
<View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
<Stage gap={24}>{stage()}</Stage>
<Inspector>
{controls()}
{changes()}
</Inspector>
</View>
) : (
<>
{controls()}
<View style={{ flex: 1, minHeight: 220, justifyContent: "center" }}>{stage()}</View>
{changes()}
</>
)
}
</View>
);
}
mount(App);HoverCard from nsh/base draws nothing of its own: the trigger is your element, the
popup and its content are yours.
use nsh_base::hover_card;
use nsh_core::prelude::*;
fn mention() -> impl IntoElement {
hover_card()
.offset(px(8.0))
.popup_class("w-72 gap-2 rounded-xl border border-border bg-popover p-4 shadow-lg")
.trigger(text("@ada").text_color(rgb(0x2563eb)))
.child(text("Ada Lovelace").font_weight(FontWeight::SEMIBOLD))
.child(text("Writes the first programs for the Analytical Engine."))
}import { Text } from "nsh";
import { HoverCard } from "nsh/base";
function Mention() {
return (
<HoverCard
offset={8}
popupClass="w-72 gap-2 rounded-xl border border-border bg-popover p-4 shadow-lg"
trigger={<Text class="text-blue-600">@ada</Text>}
>
<Text class="font-semibold">Ada Lovelace</Text>
<Text>Writes the first programs for the Analytical Engine.</Text>
</HoverCard>
);
}The trigger keeps its own role, presses and look: a view with role="link" and its own onPress still goes where it
goes when pressed, and the card adds a preview to it. The content is made each time the card opens, so it can load what it shows then. On
phones and tablets, where a press opens the card, the trigger tells screen readers whether its card is open. In a
Toolbar the trigger can be one of its items; what the card shows is not: its buttons and links keep
their own Tab stops and the toolbar’s disabled leaves them as they are.
#Delays
openDelay keeps the card from flashing open as the pointer crosses the trigger on its way elsewhere; closeDelay
gives the pointer time to cross the gap between the trigger and the card. Both are milliseconds. The pointer and the
delays are followed natively, so resting on a trigger and moving between it and the card run none of your code until
the card opens or closes. An openDelay of Infinity leaves opening to the app (and to a press on a touch screen); a
closeDelay of Infinity leaves closing to Escape, a press outside and the app.
<HoverCard openDelay={300} closeDelay={150} trigger={<Text>AAPL</Text>}>
<Quote symbol="AAPL" />
</HoverCard>
#Open state
Without open, the card keeps its own state, starting from defaultOpen. With open, the app owns it: change it in
onOpenChange({ open, reason }), which reports every request to open or close with its reason: "hover" (the
pointer), "focus" (the keyboard focus), "triggerPress" (a press on a touch screen), "pressOutside" or
"cancel" (Escape or back).
const open = signal(false);
<HoverCard open={open()} onOpenChange={(e) => open.set(e.open)} trigger={<Text>@ada</Text>}>
<Profile id="ada" />
</HoverCard>
#Entrance and exit
A hover card comes and goes with your transitions, as a Popover does. popupStartingClass is the
popup’s look in its first frame, and the transition-* classes of popupClass carry it to its own look.
popupEndingClass is its look once closed: the card stays, taking no pointer, while those transitions run, then goes,
and onClose follows. Resting on the trigger again while it goes takes it back. When the user asks for less motion, it
appears and goes at once.
<HoverCard
popupClass="rounded-xl bg-popover p-4 shadow-lg transition-all duration-150 ease-out"
popupStartingClass="opacity-0 scale-95"
popupEndingClass="opacity-0 scale-95"
trigger={<Text>@ada</Text>}
>
<Profile id="ada" />
</HoverCard>
It goes below the trigger by default (placement, align), on the other side when there is no room, and stays
inside the safe area. onPosition({ placement, bounds, available }) says where it went.
#Props
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
open | .open(..) | boolean | none | Open, controlled by the app. |
defaultOpen | .default_open(..) | boolean | false | Open at first, without open. |
openDelay | .open_delay(..) | number | 600 | Milliseconds the pointer rests on the trigger, or the focus stays on it, before it opens; Infinity: never by itself. |
closeDelay | .close_delay(..) | number | 300 | Milliseconds the pointer is away from the trigger and the card before it closes; Infinity: never by itself. |
placement | .placement(..) | "top" | "bottom" | "left" | "right" | "bottom" | The side of the trigger it goes on. |
align | .align(..) | "start" | "center" | "end" | "center" | How it lines up with the trigger along that side. |
offset | .offset(..) | number | 0 | The gap between the trigger and the card. |
margin | .margin(..) | number | 0 | The distance it keeps from the edges of the safe area. |
popupClass, popupStyle | .popup_class(..), .popup_style(..) | string, StyleProp<ViewStyle> | none | The look of the popup, the card the content is in. |
popupStartingClass, popupStartingStyle | .popup_starting_class(..), .popup_starting_style(..) | string, StyleProp<ViewStyle> | none | The popup’s look in its first frame, over its own: its transitions run from it. |
popupEndingClass, popupEndingStyle | .popup_ending_class(..), .popup_ending_style(..) | string, StyleProp<ViewStyle> | none | The popup’s look once closed, over its own: it stays while its transitions run. |
trigger | .trigger(..) | JSX.Element | none | What it previews; it is placed against it. |
children | .child(..) | JSX.Element | none | The content, made each time it opens. |
#Events
| Event | Rust | Description |
|---|---|---|
onOpenChange | .on_open_change(|e| ..) | It asks to open or close, with why: "hover", "focus", "triggerPress", "pressOutside" or "cancel". |
onOpen | .on_open(|| ..) | It is shown. |
onClose | .on_close(|| ..) | It is gone, after its exit; also when the app closed it. |
onPosition | .on_position(|e| ..) | The popup was placed: placement (the side it went on), bounds (its frame), available (the room on that side). |
#Accessibility
A hover card adds to its trigger: the trigger keeps its role and label, and what it opens or does when pressed stays the same. Keyboard users get the card when the focus rests on the trigger, and Escape closes it. The card takes no focus of its own, so Tab moves on from the trigger as before; what matters in the card should also be reachable from the page the trigger leads to.