Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/styling/custom-stylesheets.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,34 @@ app = rx.App(
)
```

### Loading Google Fonts faster

Stylesheets passed to `stylesheets` are bundled behind an `@import` chain, so the browser only
discovers the font after it has downloaded and parsed the app's global stylesheet. For fonts this
delays first paint. To load a Google Font as early as possible, use `rx.google_font` and add it to
`head_components` instead. It emits `preconnect` hints to the Google Fonts origins and requests the
font with `display=swap` so text paints immediately with a fallback face:

```python
app = rx.App(
head_components=rx.google_font("JetBrains Mono", weights=[400, 700], italic=True),
)
```

`rx.google_font` is a convenience that builds the right `<link>` tags. For a font from any other
provider, add the provider's stylesheet (and an optional `preconnect`) to `head_components` yourself:

```python
app = rx.App(
head_components=[
rx.el.link(rel="preconnect", href="https://fonts.bunny.net"),
rx.el.link(
rel="stylesheet", href="https://fonts.bunny.net/css?family=inter:400,700"
),
],
)
```

Then you can use the font in your component by setting the `font_family` prop.

```python demo
Expand Down Expand Up @@ -188,3 +216,22 @@ app = rx.App(
```

And that's it! You can now use `MyFont` like any other FontFamily to style your components.

To avoid a flash of unstyled text, preload the font file from the document head so the browser fetches
it in parallel with the page. Fonts are always fetched in CORS mode, so the `cross_origin` is required
even for same-origin files:

```python
app = rx.App(
head_components=[
rx.el.link(
rel="preload",
href="/fonts/MyFont.otf",
as_="font",
type="font/otf",
cross_origin="anonymous",
),
],
stylesheets=["/fonts/myfont.css"],
)
```
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ class Link(VoidBaseHTML):

tag = "link"

as_: Var[str] = field(
doc="Specifies the type of content being loaded, required by rel='preload'"
)

cross_origin: Var[CrossOrigin] = field(
doc="Specifies the CORS settings for the linked resource"
)
Expand Down
4 changes: 2 additions & 2 deletions pyi_hashes.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "21e51ccc7307c3c41f2556ffa7019f2c",
"packages/reflex-components-core/src/reflex_components_core/el/elements/inline.pyi": "9c1432e70e6b9349f44df04a244a4303",
"packages/reflex-components-core/src/reflex_components_core/el/elements/media.pyi": "f51120c31a1a8b79da9ecf58f19005b9",
"packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "73d19f3d9e389447ad8bbb68e1b7d1c9",
"packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "ea3986b62ca17952c1fac8d30e665cde",
"packages/reflex-components-core/src/reflex_components_core/el/elements/other.pyi": "c86abf00384b5f15725a0daf2533848d",
"packages/reflex-components-core/src/reflex_components_core/el/elements/scripts.pyi": "903432e316a781b342f2b8d334952da1",
"packages/reflex-components-core/src/reflex_components_core/el/elements/sectioning.pyi": "fbbe0bf222d4196c32c88d05cb077997",
Expand Down Expand Up @@ -118,7 +118,7 @@
"packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "1979bb6c22bb7a0d3342b2d63fb19d74",
"packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "c5288f311fe37b23539518ba2a3d4482",
"packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "2c5fadcc014056f041cd4d916137d9e7",
"reflex/__init__.pyi": "12a863ddbcac050c702a3ec6092ae17c",
"reflex/__init__.pyi": "d8131b1b8edd52a918052545e83cd54d",
"reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e",
"reflex/experimental/memo.pyi": "5bfbbd60585132d7a76840a0dbacbdd2"
}
2 changes: 1 addition & 1 deletion reflex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@
"istate.wrappers": ["get_state"],
"style": ["Style", "toggle_color_mode"],
"utils.imports": ["ImportDict", "ImportVar"],
"utils.misc": ["run_in_thread"],
"utils.misc": ["google_font", "run_in_thread"],
"utils.serializers": ["serializer"],
"vars": ["Var", "field", "Field", "RestProp", "EMPTY_VAR_STR", "EMPTY_VAR_INT"],
}
Expand Down
52 changes: 50 additions & 2 deletions reflex/utils/misc.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""Miscellaneous functions for the experimental package."""
"""Miscellaneous utility functions."""

import asyncio
import contextlib
import inspect
import sys
import threading
from collections.abc import Callable
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -131,3 +131,51 @@ def preload_color_theme():
"""

return Script.create(script_content)


def google_font(
family: str,
*,
weights: Sequence[int] = (400,),
italic: bool = False,
display: str = "swap",
) -> list:
"""Create the components that load a Google Font from the document head.
Adding these to ``head_components`` (instead of ``rx.App(stylesheets=...)``, which chains
fonts behind an ``@import`` in the global stylesheet) lets the browser discover the font
during the initial HTML parse and fetch it in parallel, with ``display=swap`` so text paints
immediately using a fallback face::
app = rx.App(head_components=rx.google_font("Inter", weights=[400, 700]))
Args:
family: The font family name, e.g. ``"Open Sans"``.
weights: The font weights to request.
italic: Whether to also request italic styles for each weight.
display: The CSS ``font-display`` strategy.
Returns:
The preconnect and stylesheet components to add to ``head_components``.
"""
from reflex_components_core.el.elements.metadata import Link

if not weights:
msg = "weights must not be empty"
raise ValueError(msg)
family_param = family.replace(" ", "+")
sorted_weights = sorted(weights)
Comment thread
FarhanAliRaza marked this conversation as resolved.
if italic:
axis = "ital,wght@" + ";".join(
f"{style},{weight}" for style in (0, 1) for weight in sorted_weights
)
else:
axis = "wght@" + ";".join(str(weight) for weight in sorted_weights)
href = f"https://fonts.googleapis.com/css2?family={family_param}:{axis}&display={display}"
return [
Link.create(rel="preconnect", href="https://fonts.googleapis.com"),
Link.create(
rel="preconnect", href="https://fonts.gstatic.com", cross_origin="anonymous"
),
Link.create(rel="stylesheet", href=href),
]
14 changes: 14 additions & 0 deletions tests/units/components/el/test_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from reflex_components_core.el.elements.metadata import Link


def test_link_as_prop():
"""The link element renders the as_ prop as the HTML `as` attribute (for rel='preload')."""
props = Link.create(
rel="preload",
href="/fonts/My.woff2",
as_="font",
type="font/woff2",
cross_origin="anonymous",
).render()["props"]
assert 'as:"font"' in props
assert 'rel:"preload"' in props
53 changes: 53 additions & 0 deletions tests/units/utils/test_misc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Tests for reflex/utils/misc.py."""

from __future__ import annotations

import reflex as rx
from reflex.utils.misc import google_font


def _props(component: rx.Component) -> list[str]:
"""Return the rendered prop strings of a component.
Args:
component: The component to render.
Returns:
The list of ``name:value`` prop strings.
"""
return component.render()["props"]


def test_google_font_exported():
"""google_font is exposed as rx.google_font."""
assert rx.google_font is google_font


def test_google_font_default():
"""google_font returns preconnects plus a swap stylesheet with a wght axis."""
first, second, sheet = (_props(c) for c in google_font("Inter", weights=[700, 400]))

assert 'rel:"preconnect"' in first
assert 'href:"https://fonts.googleapis.com"' in first
assert 'rel:"preconnect"' in second
assert 'crossOrigin:"anonymous"' in second

href = "https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap"
assert 'rel:"stylesheet"' in sheet
assert f'href:"{href}"' in sheet


def test_google_font_italic_and_spaces():
"""google_font encodes spaces and builds the ital,wght axis when italic=True."""
sheet = google_font("Open Sans", weights=[400, 700], italic=True)[-1]
href = (
"https://fonts.googleapis.com/css2?family=Open+Sans:"
"ital,wght@0,400;0,700;1,400;1,700&display=swap"
)
assert f'href:"{href}"' in _props(sheet)


def test_google_font_display_override():
"""google_font respects a custom font-display strategy."""
sheet = google_font("Roboto", display="optional")[-1]
assert "&display=optional" in "".join(_props(sheet))
Loading