font
CSS-style font chains: bundled, fetched and installed fonts, and what each entry resolved to.
egui draws text from font bytes it was handed: the browser's fonts and the OS's font matching are never involved, and egui's own four fonts have no CJK glyphs, so Japanese draws as boxes in a plain egui app. This example is the answer — four named chains, one per kind of source. bundled is a 433 KB subset compiled in with include_bytes!; web fetches the full 4.5 MB Noto Sans JP from the app's own origin; system asks the machine for its installed fonts; and code is the monospace chain, with the subset behind egui's Hack for the kana and kanji Hack does not have.
The report table under the samples is the point of it. For every entry of every chain it says which face it resolved to — with the family name that face declares, which is how you find the spelling a System entry needs — or why it did not. Pick the system stack natively and you can see fontconfig's answer; pick it in a browser and you get the Local Font Access route, which is Chromium only, needs a permission prompt, and must be triggered by a click, so every other browser shows the button disabled with the reason.
The embed above fetches a 4.5 MB font on demand. Choosing the web stack starts that download; until it lands, the bundled subset behind it in the chain draws the text, and the entry flips from pending to loaded when the bytes arrive. That is CSS's swap, and the font-display row switches it for block, which draws a placeholder until Fonts::pending() goes false. The library only reports whether a URL is still in flight; which policy an app wants is the app's call. Fonts has the rest.
Run it yourself
cargo run -p font
trunk serve --config examples/font/Trunk.tomlThe web font is not committed: a pre-build hook in the example's Trunk.toml downloads it the first time trunk builds. Natively the same relative URL has no server to point at, so that entry is reported as failed and the chain draws with the subset — the report shows that too.
The source is examples/font/src/lib.rs.
use std::sync::LazyLock;
use egui_reactor::prelude::*;
use egui_reactor_app::fonts::{FontSource, Fonts, Generic, Outcome};
use egui_reactor_elements::prelude::*;
/// The subset of Noto Sans JP compiled into the binary: ASCII, Latin-1, kana,
/// CJK punctuation, fullwidth forms and about five hundred kanji.
const NOTO_SANS_JP_SUBSET: &[u8] = include_bytes!("../fonts/NotoSansJP-Subset.ttf");
/// Where the `web` stack fetches the full font from: next to `index.html`.
pub const WEB_FONT_URL: &str = "fonts/NotoSansJP-Regular.otf";
/// The stacks, in the order the buttons show them.
pub const STACKS: [&str; 4] = ["bundled", "web", "system", "code"];
/// The stack an app normally uses on the target this build runs on, and the one
/// the example starts on. Natively `system`: the fonts are already installed, so
/// there is nothing to ship. On wasm `web`: the browser cannot read installed
/// fonts, so the app serves one from its own origin — `bundled` instead when a
/// subset is small enough, and then there is nothing to wait for either.
pub fn typical() -> &'static str {
if cfg!(target_arch = "wasm32") {
"web"
} else {
"system"
}
}
/// The `★` line under the stack buttons: why that stack is the typical one here.
pub fn typical_note() -> &'static str {
if cfg!(target_arch = "wasm32") {
"★ = what an app normally uses on this target: a font fetched from the app's own origin \
(a bundled subset when it is small enough)"
} else {
"★ = what an app normally uses on this target: fonts installed on the machine, nothing to \
ship"
}
}
/// One row per source kind: the stack, then what an app does with it natively
/// and in the browser. Drawn as the little matrix under `how`, because the
/// answer is a pair and picking one target hides half of it.
pub const MATRIX: [(&str, &str, &str); 3] = [
(
"bundled",
"typical for small subsets",
"typical for small subsets",
),
("web", "works (any HTTP URL)", "typical"),
(
"system",
"typical",
"opt-in: Local Font Access, Chromium only, permission prompt",
),
];
/// `▶ ` in front of the matrix column this build runs on, so the reader knows
/// which of the two they are reading. `column` is `"native"` or `"web"`.
pub fn here(column: &str) -> &'static str {
if (column == "web") == cfg!(target_arch = "wasm32") {
"▶ "
} else {
""
}
}
/// The two `font-display` policies the example can draw, first is the default.
pub const DISPLAYS: [&str; 2] = ["swap", "block"];
/// What `block` draws while a URL is still in flight.
pub const LOADING: &str = "loading fonts…";
/// What is drawn with the selected stack. Every character is in the subset.
pub const SAMPLES: [&str; 5] = [
"日本語のテキストが表示できます。",
"フォントのフォールバックチェーン",
"吾輩は猫である。名前はまだ無い。",
"東京都渋谷区、2026年9月7日",
"egui-reactor で CSS の font-family のように書ける",
];
/// The one `Fonts` of the process. `Fonts` is a handle (a clone shares the
/// database), so `main.rs` applies it from `Options::setup`, `App` applies
/// it on its first frame, and the components read its report every frame.
static FONTS: LazyLock<Fonts> = LazyLock::new(build_fonts);
/// A handle to the process-wide [`Fonts`].
pub fn fonts() -> Fonts {
FONTS.clone()
}
/// The three chains, as an app would write them.
pub fn build_fonts() -> Fonts {
Fonts::new()
.stack(
"bundled",
[
FontSource::Bundled(NOTO_SANS_JP_SUBSET),
FontSource::Generic(Generic::SansSerif),
],
)
.stack(
"web",
[
FontSource::Url(WEB_FONT_URL.into()),
FontSource::Bundled(NOTO_SANS_JP_SUBSET),
FontSource::Generic(Generic::SansSerif),
],
)
.stack(
"system",
[
FontSource::System("Hiragino Sans".into()),
FontSource::System("Yu Gothic UI".into()),
FontSource::System("Noto Sans CJK JP".into()),
FontSource::System("Noto Sans JP".into()),
FontSource::Generic(Generic::SansSerif),
],
)
.stack(
"code",
[
FontSource::Generic(Generic::Monospace),
FontSource::Bundled(NOTO_SANS_JP_SUBSET),
FontSource::Generic(Generic::SansSerif),
],
)
.default_proportional("bundled")
.default_monospace("code")
}
#[component]
pub fn App(cx: &mut Cx) {
// Start on the stack an app would actually use here, not on the first of
// the list: the example is read as advice as much as a demo.
let mut stack = use_state(cx, typical);
let current: &'static str = *stack;
let mut display = use_state(cx, || DISPLAYS[0]);
let policy: &'static str = *display;
// `pending()` is read every frame; the fetch that finishes asks for a
// repaint, so the samples come back on their own when the bytes land.
let waiting = policy == "block" && fonts().pending();
let weak = cx.ui().visuals().weak_text_color();
// The gallery runs `App` inside its own runner and has no `setup` hook
// per example, so the stacks are applied here, once. Under `main.rs` the
// setup already did it and this changes nothing: `apply` only calls
// `set_fonts` when the definitions differ from the last time.
let ctx = cx.ctx().clone();
use_effect(cx, (), move || {
fonts().apply(&ctx);
// New fonts take effect at the start of the next pass.
ctx.request_repaint();
});
// On the frame that applied them the names are not registered yet, and
// `<Text font>` would warn about that (once) before falling back. The
// built-in family is always registered, so it stands in for that frame.
let ready = cx.ctx().fonts(|f| {
f.definitions()
.families
.contains_key(&egui::FontFamily::Name(current.into()))
});
let font = if ready { current } else { "proportional" };
rsx! {
// The root fills whatever area it is given, so the gallery can drop it
// into a column of its own.
<View direction="column" grow={1.0}>
<ScrollArea grow={1.0}>
<View direction="column" gap={8} p={12} w="100%">
<Text size={22.0} strong>"font"</Text>
<View direction="row" gap={8} align="center">
<Text>"stack:"</Text>
for name in STACKS {
// The typical one wears the star; the note under
// the row says what the star means.
<Button key={name} on_click={|| *stack = name}>
{if name == typical() {
format!("{name} ★")
} else {
name.to_string()
}}
</Button>
}
<Text>{format!("<Text font=\"{current}\">")}</Text>
</View>
<Text wrap w="100%">{typical_note()}</Text>
// How this stack gets its bytes on the target this build
// runs on; `wrap` needs a width, which `w` gives it.
<Text wrap w="100%">{how(current)}</Text>
<Matrix/>
// What to draw while a URL is in flight. The report below
// keeps showing that entry going pending → loaded either
// way; only the samples change.
<View direction="row" gap={8} align="center" w="100%">
<Text>"font-display:"</Text>
for name in DISPLAYS {
<Button key={name} on_click={|| *display = name}>{name}</Button>
}
<Text grow={1.0} wrap>{policy_note(policy)}</Text>
</View>
if waiting {
// egui has no "laid out but invisible" text, so FOIT
// is a placeholder in the samples' place.
<Text size={20.0} color={weak}>{LOADING}</Text>
} else {
<View direction="column" gap={4}>
for (i, sample) in SAMPLES.iter().enumerate() {
<Text key={i} font={font} size={20.0}>{*sample}</Text>
}
</View>
}
<Separator/>
<LocalFonts/>
<Separator/>
<Report/>
</View>
</ScrollArea>
</View>
}
}
/// The two targets side by side: which stack an app uses where, in three rows.
/// `how` only speaks about the target this build runs on, and the choice is
/// really a pair, so the other column is spelled out rather than implied. The
/// column the build is on is marked, since both are drawn the same.
#[component]
fn Matrix(cx: &mut Cx) {
rsx! {
<View direction="column" gap={2} w="100%">
<Text strong>"where each stack gets its bytes"</Text>
<View direction="row" gap={8} w="100%">
<Text w={80.0} strong>"stack"</Text>
<Text grow={1.0} strong>{format!("{}native", here("native"))}</Text>
<Text grow={1.0} strong>{format!("{}web", here("web"))}</Text>
</View>
for (i, (name, native, web)) in MATRIX.iter().enumerate() {
<View key={i} direction="row" gap={8} w="100%">
<Text w={80.0}>{*name}</Text>
// The cells wrap, so the widths have to come from the row:
// `w` for the name, `grow` for the two that can be long.
<Text grow={1.0} wrap>{format!("{}{native}", here("native"))}</Text>
<Text grow={1.0} wrap>{format!("{}{web}", here("web"))}</Text>
</View>
}
</View>
}
}
/// The Local Font Access button. On wasm it is enabled when the browser has
/// `window.queryLocalFonts`; everywhere else it is disabled with the reason next to
/// it, so the example is one source file.
#[component]
fn LocalFonts(cx: &mut Cx) {
// The request is `async` and finishes on the browser's event loop (on a
// thread of its own natively, where it answers `Ok(0)` at once), so the
// result comes back through a `Dispatch`: it is `Send`, and `send` asks
// for the repaint that shows it.
let (status, dispatch) = use_reducer(
cx,
|status: &mut String, message: String| *status = message,
String::new,
);
let available = Fonts::local_fonts_available();
let note = if cfg!(target_arch = "wasm32") {
if available {
"Chromium asks for permission once; the \"system\" stack is then resolved from the grant."
} else {
"Local Font Access: Chromium only"
}
} else {
"installed fonts are loaded automatically"
};
rsx! {
<View direction="column" gap={4} w="100%">
<View direction="row" gap={8} align="center" w="100%">
<Button enabled={available} on_click={|| {
let fonts = fonts();
let dispatch = dispatch.clone();
// `spawn` is `wasm_bindgen_futures::spawn_local` on wasm, so
// the call still counts as coming from the click, which the
// permission prompt requires.
spawn(async move {
let message = match fonts.request_local_fonts().await {
Ok(n) => format!("{n} of the named families found on this machine"),
Err(err) => format!("local fonts: {err}"),
};
dispatch.send(message);
});
}}>
"Use my fonts"
</Button>
// The note is long; `grow` gives `wrap` a width instead of
// letting the row run past the pane.
<Text grow={1.0} wrap>{note}</Text>
</View>
if !status.is_empty() {
<Text strong>{status.as_str()}</Text>
}
</View>
}
}
/// The report: every entry of every stack and what it became on the last
/// `apply`. Read every frame; it is a lock and a clone of a dozen entries, and
/// it is how the `web` entry is seen going from `pending` to `loaded`.
#[component]
fn Report(cx: &mut Cx) {
let report = fonts().report();
rsx! {
<View direction="column" gap={4} w="100%">
<Text strong>"report"</Text>
for (i, stack) in report.iter().enumerate() {
<View key={i} direction="column" gap={2} w="100%" mt={4}>
<Text strong>{format!("stack \"{}\"", stack.name)}</Text>
for (j, (source, outcome)) in stack.entries.iter().enumerate() {
<View key={j} direction="row" gap={8} w="100%">
<Text w={280.0}>{source.to_string()}</Text>
// A failure message can be long; `wrap` needs a
// width, which `grow` gives it in a sized row.
<Text grow={1.0} wrap>{describe(outcome)}</Text>
</View>
}
</View>
}
</View>
}
}
/// How a stack gets its bytes on the target this build runs on. Shown under
/// the stack buttons, so the report below it can be read as "this is what
/// that produced".
pub fn how(stack: &str) -> &'static str {
let wasm = cfg!(target_arch = "wasm32");
match (stack, wasm) {
("bundled", true) => {
"Compiled into the .wasm with include_bytes! (a 433 KB subset of Noto Sans JP) and \
registered from Options::setup, before the first frame. Nothing to wait for and \
nothing to fetch; the cost is binary size, which is why it is a subset."
}
("bundled", false) => {
"Compiled into the binary with include_bytes! (a 433 KB subset of Noto Sans JP) and \
registered from Options::setup, before the first frame."
}
("web", true) => {
"fetch() of fonts/NotoSansJP-Regular.otf (4.5 MB) from the page's own origin, \
started by the first apply. Until the bytes arrive the entry is pending and the \
subset behind it draws; when they land the chains are applied again and egui \
rebuilds its glyph atlas once. A cross-origin URL would need CORS headers."
}
("web", false) => {
"An HTTP fetch (ureq on a thread). Here the relative URL has no server to point at, \
so the entry is failed and the subset behind it draws; in the browser the same \
URL is fetched from the page's origin."
}
("system", true) => {
"The browser cannot read installed fonts, so each name is missing until \"Use my \
fonts\" asks for them through the Local Font Access API (Chromium only, a \
permission prompt, has to start from a click). The granted files go into the same \
database and this chain is resolved again. \"Noto Sans JP\" already matches the \
bundled subset, which is why Japanese draws before any grant."
}
("system", false) => {
"fontdb scans the OS font directories once (load_system_fonts) and each name is \
matched against the installed families as CSS would; a name no font declares is \
missing and skipped, and the report shows the family each face declares."
}
("code", _) => {
"The monospace generic first (egui's Hack in the browser, the installed monospace \
default natively), then the bundled subset for the kana and kanji Hack lacks. It \
is also the Monospace default, which the gallery's source pane draws with."
}
_ => "",
}
}
/// What the selected `font-display` policy does, in one line.
pub fn policy_note(policy: &str) -> &'static str {
match policy {
"block" => "draw a placeholder until every URL arrived or failed (FOIT)",
_ => "draw with what stands behind a pending URL, then swap (FOUT)",
}
}
/// One line per outcome, for the report table.
pub fn describe(outcome: &Outcome) -> String {
match outcome {
Outcome::Loaded { key, family } => format!("loaded: {family} ({key})"),
Outcome::Pending => String::from("pending: the bytes have not arrived yet"),
Outcome::Missing => String::from("missing: no face with this family name"),
Outcome::Invalid(why) => format!("invalid: {why}"),
Outcome::Failed(why) => format!("failed: {why}"),
}
}