diff --git a/examples/webpage/README.md b/examples/webpage/README.md
index 8c2d9c5..401b233 100644
--- a/examples/webpage/README.md
+++ b/examples/webpage/README.md
@@ -34,13 +34,34 @@ trunk serve --open
| Module | What lives there |
| --- | --- |
| `lib.rs` | App shell: router, sticky header with active-route indicator, scroll-progress rail, docs shell and prev/next pager, footer |
-| `fx.rs` | Reusable reactive effects — pointer spotlight, 3D tilt, magnetic controls, aurora background, film grain, scroll-spy, marquee, kinetic headlines |
+| `fx.rs` | Reusable reactive effects — pointer spotlight, 3D tilt, magnetic controls, aurora background, film grain, scroll-spy, marquee, kinetic headlines, glass and scrollbar surfaces |
+| `keyframes.rs` | Every animation on the site, declared with `dwkeyframes!` |
| `palette.rs` | The ⌘K command palette: fuzzy search over every route, full keyboard control |
-| `styles.rs` | App-level raw CSS — keyframes, glass surfaces, grain, masks |
| `reveal.rs` | `IntersectionObserver`-driven progressive reveal on scroll |
| `pages/signal_lab.rs` | The reactivity demo on the home page |
| `pages/docs/` | Documentation pages, sidebar, live example frames, syntax-highlighted source |
+### There is no app stylesheet
+
+This example used to carry ~300 lines of hand-written CSS. It now carries none.
+The only `
diff --git a/examples/webpage/src/fx.rs b/examples/webpage/src/fx.rs
index e9b1386..efc462e 100644
--- a/examples/webpage/src/fx.rs
+++ b/examples/webpage/src/fx.rs
@@ -5,10 +5,11 @@
//! diff: an event writes a number, the signal writes one CSS property on one
//! node, and the compositor does the rest.
+use crate::keyframes::*;
use dominator::{events, html, Dom, DomBuilder};
use dwind::prelude::*;
use dwind_macros::dwclass;
-use futures_signals::signal::{Mutable, SignalExt};
+use futures_signals::signal::{Mutable, Signal, SignalExt};
use web_sys::HtmlElement;
/// Normalised pointer position inside an element, plus whether it is hovered.
@@ -39,10 +40,14 @@ fn normalised(element: &HtmlElement, client_x: f64, client_y: f64) -> (f64, f64)
)
}
-/// Wires pointer tracking into a builder and exposes the state.
+/// Wires pointer tracking into a builder, and paints the two pseudo-elements
+/// that follow the cursor.
///
-/// `--sx` / `--sy` are written on every pointer move; the `.dw-spot` rules in
-/// [`crate::styles`] park a radial gradient and a lit border edge there.
+/// The glow (`::before`) and the lit border edge (`::after`) are parked at
+/// `--sx` / `--sy`, which the handlers below write on every pointer move. Both
+/// used to be raw CSS: `content: ""` is now emitted automatically for
+/// `::before` / `::after` variants, and the mask/blend declarations that have no
+/// utility go through the arbitrary-declaration escape hatch.
fn track_pointer(
builder: DomBuilder,
state: &PointerState,
@@ -70,11 +75,27 @@ fn track_pointer(
}
})
})
- .class("dw-spot")
- .attr_signal(
- "data-hot",
- hot.signal().map(|h| Some(if h { "1" } else { "0" })),
- )
+ .apply(|b| dwclass!(b, "relative isolate [transition:transform 400ms cubic-bezier(0.16, 1, 0.3, 1), border-color 300ms ease]"))
+ // The glow.
+ .apply(|b| dwclass!(b, "\
+ [&::before]:absolute [&::before]:inset-0 [&::before]:[z-index:-1] \
+ [&::before]:[border-radius:inherit] [&::before]:opacity-0 \
+ [&::before]:[transition:opacity 320ms ease] [&.hot::before]:opacity-100 \
+ [&::before]:[background:radial-gradient(22rem circle at var(--sx, 50%) var(--sy, 50%), rgba(213, 182, 95, 0.13), transparent 62%)]"))
+ // A one-pixel gradient ring, cut out of a solid fill with a mask so only the
+ // border shows. Four co-dependent declarations, none with a utility.
+ .apply(|b| dwclass!(b, "\
+ [&::after]:absolute [&::after]:inset-0 [&::after]:[z-index:-1] \
+ [&::after]:[border-radius:inherit] [&::after]:[padding:1px] \
+ [&::after]:opacity-0 [&::after]:[transition:opacity 320ms ease] [&.hot::after]:opacity-100 \
+ [&::after]:[background:radial-gradient(16rem circle at var(--sx, 50%) var(--sy, 50%), rgba(213, 182, 95, 0.55), transparent 55%)] \
+ [&::after]:[-webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)] \
+ [&::after]:[-webkit-mask-composite:xor] \
+ [&::after]:[mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)] \
+ [&::after]:[mask-composite:exclude]"))
+ // The pseudo-element rules key off this class rather than a data attribute,
+ // because a class is what a `[&.hot::before]:` variant can select.
+ .class_signal("hot", hot.signal())
.style_signal(
"--sx",
pos.signal().map(|(x, _)| format!("{:.2}%", x * 100.0)),
@@ -94,9 +115,49 @@ pub fn spotlight(builder: DomBuilder) -> DomBuilder {
track_pointer(builder, &PointerState::new())
}
+/// Whether the reader has asked for less motion.
+///
+/// A CSS `@media` block can shorten a transition, but it cannot stop a
+/// `style_signal` from writing a transform in the first place. The effects below
+/// are driven from Rust, so the preference has to be read in Rust too — as a
+/// signal, so it also tracks a change made while the page is open.
+fn prefers_reduced_motion() -> impl Signal- {
+ dominator::media_query("(prefers-reduced-motion: reduce)")
+}
+
+/// The 3D tilt transform for a card, or the resting one.
+///
+/// Split out from the signal wiring so the reduced-motion rule is a plain
+/// assertion rather than something only a browser can check.
+fn tilt_transform(x: f64, y: f64, hot: bool, still: bool, strength: f64) -> String {
+ if !hot || still {
+ return "perspective(1100px) rotateX(0deg) rotateY(0deg) translateZ(0)".to_string();
+ }
+
+ format!(
+ "perspective(1100px) rotateX({:.2}deg) rotateY({:.2}deg) translateZ(6px)",
+ (0.5 - y) * strength * 2.0,
+ (x - 0.5) * strength * 2.0,
+ )
+}
+
+/// The magnetic offset for a control, or the resting one.
+fn magnetic_transform(x: f64, y: f64, hot: bool, still: bool, pull: f64) -> String {
+ if !hot || still {
+ return "translate3d(0, 0, 0)".to_string();
+ }
+
+ format!(
+ "translate3d({:.2}px, {:.2}px, 0)",
+ (x - 0.5) * pull * 2.0,
+ (y - 0.5) * pull * 2.0,
+ )
+}
+
/// A spotlight card that also tips towards the cursor in 3D.
///
-/// `strength` is the maximum rotation in degrees.
+/// `strength` is the maximum rotation in degrees. The tilt is suppressed
+/// entirely under `prefers-reduced-motion`.
pub fn spotlight_tilt(
strength: f64,
) -> impl Fn(DomBuilder) -> DomBuilder {
@@ -108,17 +169,9 @@ pub fn spotlight_tilt(
"transform",
futures_signals::map_ref! {
let (x, y) = pos.signal(),
- let hot = hot.signal() => move {
- if *hot {
- format!(
- "perspective(1100px) rotateX({:.2}deg) rotateY({:.2}deg) translateZ(6px)",
- (0.5 - *y) * strength * 2.0,
- (*x - 0.5) * strength * 2.0,
- )
- } else {
- "perspective(1100px) rotateX(0deg) rotateY(0deg) translateZ(0)".to_string()
- }
- }
+ let hot = hot.signal(),
+ let still = prefers_reduced_motion() =>
+ move { tilt_transform(*x, *y, *hot, *still, strength) }
},
)
}
@@ -126,6 +179,10 @@ pub fn spotlight_tilt(
/// A control that drifts toward the cursor while hovered — the classic
/// "magnetic button", in about twenty lines of signal plumbing.
+///
+/// Suppressed under `prefers-reduced-motion`, for the same reason as
+/// [`spotlight_tilt`]: this is a `style_signal` write, so no `@media` block can
+/// stop it.
pub fn magnetic(pull: f64) -> impl Fn(DomBuilder) -> DomBuilder {
move |builder| {
let pos = Mutable::new((0.5f64, 0.5f64));
@@ -160,17 +217,9 @@ pub fn magnetic(pull: f64) -> impl Fn(DomBuilder) -> DomBuilder move {
- if *hot {
- format!(
- "translate3d({:.2}px, {:.2}px, 0)",
- (*x - 0.5) * pull * 2.0,
- (*y - 0.5) * pull * 2.0,
- )
- } else {
- "translate3d(0, 0, 0)".to_string()
- }
- }
+ let hot = hot.signal(),
+ let still = prefers_reduced_motion() =>
+ move { magnetic_transform(*x, *y, *hot, *still, pull) }
},
)
}
@@ -184,45 +233,46 @@ pub fn magnetic(pull: f64) -> impl Fn(DomBuilder) -> DomBuilder Dom {
html!("div", {
.attr("aria-hidden", "true")
- .style("position", "fixed")
- .style("inset", "0")
- .style("z-index", "0")
- .style("pointer-events", "none")
- .style("overflow", "hidden")
+ .dwclass!("fixed inset-0 z-0 pointer-events-none overflow-hidden")
.child(aurora_blob(
"radial-gradient(circle, rgba(213, 182, 95, 0.30) 0%, rgba(213, 182, 95, 0.08) 40%, transparent 70%)",
- "-22%", "54%", "66rem", "dwind-aurora-a 22s ease-in-out infinite",
+ "-22%", "54%", "66rem",
+ &format!("{AURORA_A_KEYFRAMES} 22s ease-in-out infinite"),
))
.child(aurora_blob(
"radial-gradient(circle, rgba(95, 176, 213, 0.16) 0%, transparent 68%)",
- "38%", "-18%", "52rem", "dwind-aurora-b 28s ease-in-out infinite",
+ "38%", "-18%", "52rem",
+ &format!("{AURORA_B_KEYFRAMES} 28s ease-in-out infinite"),
))
.child(aurora_blob(
"radial-gradient(circle, rgba(213, 95, 168, 0.10) 0%, transparent 70%)",
- "74%", "62%", "46rem", "dwind-aurora-a 34s ease-in-out infinite reverse",
+ "74%", "62%", "46rem",
+ &format!("{AURORA_A_KEYFRAMES} 34s ease-in-out infinite reverse"),
))
})
}
fn aurora_blob(background: &str, top: &str, left: &str, size: &str, animation: &str) -> Dom {
html!("div", {
- .style("position", "absolute")
+ .dwclass!("absolute will-change-transform [filter:blur(20px)]")
.style("top", top)
.style("left", left)
.style("width", size)
.style("height", size)
.style("background", background)
- .style("filter", "blur(20px)")
- .style("will-change", "transform")
.style("animation", animation)
})
}
/// Fixed film-grain overlay. Costs one node for the whole document.
+///
+/// The texture is an inline `feTurbulence` SVG. That data URI stays a plain
+/// `.style()` — it is a one-off asset, not a reusable utility value.
pub fn grain() -> Dom {
html!("div", {
.attr("aria-hidden", "true")
- .class("dw-grain")
+ .dwclass!("fixed inset-0 pointer-events-none opacity-20 mix-blend-overlay [z-index:9998]")
+ .style("background-image", "url(\"data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'%3E%3Cfilter id=\'n\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.85\' numOctaves=\'3\' stitchTiles=\'stitch\'/%3E%3CfeColorMatrix type=\'saturate\' values=\'0\'/%3E%3C/filter%3E%3Crect width=\'140\' height=\'140\' filter=\'url(%23n)\' opacity=\'0.5\'/%3E%3C/svg%3E\")")
})
}
@@ -230,11 +280,9 @@ pub fn grain() -> Dom {
pub fn blueprint_grid(mask: &str) -> Dom {
html!("div", {
.attr("aria-hidden", "true")
- .style("position", "absolute")
- .style("inset", "0")
- .style("pointer-events", "none")
- .style("background-image", "linear-gradient(rgba(125, 125, 135, 0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(125, 125, 135, 0.06) 1px, transparent 1px)")
- .style("background-size", "48px 48px")
+ .dwclass!("absolute inset-0 pointer-events-none \
+ [background-image:linear-gradient(rgba(125, 125, 135, 0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(125, 125, 135, 0.06) 1px, transparent 1px)] \
+ [background-size:48px 48px]")
.style("mask-image", mask)
.style("-webkit-mask-image", mask)
})
@@ -291,7 +339,7 @@ pub fn spy_rail(
html!("nav", {
.attr("aria-label", "On this page")
.dwclass!("flex flex-col gap-1 flex-none w-44 @) -> DomBuilder {
+ dwclass!(
+ builder,
+ "animate-sheen \
+ [background-image:linear-gradient(100deg, #F0E2B6 0%, #D5B65F 18%, #FFF8E2 30%, #D5B65F 42%, #A88735 60%, #D5B65F 100%)] \
+ [background-size:200% auto] \
+ [-webkit-background-clip:text] [background-clip:text] [color:transparent]"
+ )
+}
+
/// One headline word, animated in on its own delay.
pub fn word(content: &str, index: usize, accent: bool) -> Dom {
html!("span", {
- .class("dw-word")
- .apply_if(accent, |b| b.class("dw-sheen"))
- .style("animation-delay", &format!("{}ms", 90 * index as u32))
- .style("padding-right", "0.26em")
+ .dwclass!("inline-block [padding-right:0.26em]")
+ .apply_if(accent, sheen)
+ // Formatting the handle registers the @keyframes, so a computed delay
+ // can never reference a rule that was not injected.
+ .style("animation", &format!(
+ "{WORD_IN_KEYFRAMES} 900ms {}ms cubic-bezier(0.16, 1, 0.3, 1) both",
+ 90 * index as u32,
+ ))
.text(content)
})
}
@@ -366,21 +433,96 @@ pub fn marquee(items: &[&str]) -> Dom {
let chip = |label: &str| {
html!("span", {
.class("font-code")
- .dwclass!("text-xs text-woodsmoke-400 flex-none")
+ .dwclass!("text-xs text-woodsmoke-400 flex-none whitespace-nowrap")
.dwclass!("border border-woodsmoke-800 rounded-full p-l-4 p-r-4 p-t-2 p-b-2 m-r-3")
- .style("background", "rgba(18, 18, 21, 0.55)")
- .style("white-space", "nowrap")
+ .dwclass!("[background:rgba(18, 18, 21, 0.55)]")
.text(label)
})
};
html!("div", {
.attr("aria-hidden", "true")
- .class("dw-marquee")
- .dwclass!("w-full overflow-hidden")
+ .dwclass!("w-full overflow-hidden \
+ [mask-image:linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent)] \
+ [-webkit-mask-image:linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent)]")
+ // Hover on the parent, effect on the child — the classic case that no
+ // element-level class can express, and a plain child variant here.
+ .dwclass!("[&:hover > *]:[animation-play-state:paused]")
.child(html!("div", {
- .class("dw-marquee-track")
+ .dwclass!("flex animate-marquee [width:max-content]")
.children(items.iter().chain(items.iter()).map(|i| chip(i)))
}))
})
}
+
+// ---------------------------------------------------------------------------
+// Surfaces
+// ---------------------------------------------------------------------------
+
+/// The translucent panel used by cards, the header and the docs sidebar.
+///
+/// Three co-dependent declarations that only make sense together — a component
+/// surface rather than a utility, so it lives here as one named mixin.
+pub fn glass(builder: DomBuilder) -> DomBuilder {
+ dwclass!(
+ builder,
+ "[background:linear-gradient(160deg, rgba(28, 28, 33, 0.72) 0%, rgba(14, 14, 17, 0.62) 100%)] \
+ [backdrop-filter:blur(14px) saturate(1.2)] \
+ [box-shadow:inset 0 1px 0 0 rgba(255, 255, 255, 0.045)]"
+ )
+}
+
+/// Thin scrollbars that match the surface they sit on.
+///
+/// WebKit exposes these as pseudo-elements, which is exactly what the bracketed
+/// variant syntax selects. Firefox uses the standard `scrollbar-*` properties,
+/// set alongside.
+pub fn slim_scrollbar(builder: DomBuilder) -> DomBuilder {
+ dwclass!(
+ builder,
+ "[scrollbar-width:thin] [scrollbar-color:#26262C transparent] \
+ [&::-webkit-scrollbar]:[width:10px] [&::-webkit-scrollbar]:[height:10px] \
+ [&::-webkit-scrollbar-track]:[background:transparent] \
+ [&::-webkit-scrollbar-thumb]:[background:#26262C] \
+ [&::-webkit-scrollbar-thumb]:[border-radius:8px] \
+ [&::-webkit-scrollbar-thumb]:[border:3px solid transparent] \
+ [&::-webkit-scrollbar-thumb]:[background-clip:content-box] \
+ [&::-webkit-scrollbar-thumb:hover]:[background:#3A3A44] \
+ [&::-webkit-scrollbar-thumb:hover]:[background-clip:content-box]"
+ )
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ // `prefers-reduced-motion` is honoured in CSS for animations and
+ // transitions, but these two effects are `style_signal` writes that no
+ // `@media` block can reach — so the rule lives in Rust and is asserted here.
+
+ #[test]
+ fn tilt_is_suppressed_under_reduced_motion() {
+ let moving = tilt_transform(0.2, 0.25, true, false, 4.0);
+ let still = tilt_transform(0.2, 0.25, true, true, 4.0);
+ let resting = tilt_transform(0.2, 0.25, false, false, 4.0);
+
+ assert!(moving.contains("rotateX(2.00deg)"), "{moving}");
+ assert_eq!(still, resting);
+ assert!(still.contains("rotateX(0deg)"), "{still}");
+ assert!(still.contains("rotateY(0deg)"), "{still}");
+ }
+
+ #[test]
+ fn magnetic_drift_is_suppressed_under_reduced_motion() {
+ let moving = magnetic_transform(1.0, 1.0, true, false, 7.0);
+ let still = magnetic_transform(1.0, 1.0, true, true, 7.0);
+ let resting = magnetic_transform(1.0, 1.0, false, false, 7.0);
+
+ assert!(
+ moving.contains("translate3d(7.00px, 7.00px, 0)"),
+ "{moving}"
+ );
+ assert_eq!(still, "translate3d(0, 0, 0)");
+ assert_eq!(still, resting);
+ }
+}
diff --git a/examples/webpage/src/keyframes.rs b/examples/webpage/src/keyframes.rs
new file mode 100644
index 0000000..8a14002
--- /dev/null
+++ b/examples/webpage/src/keyframes.rs
@@ -0,0 +1,88 @@
+//! Every animation on the site, declared in Rust.
+//!
+//! These used to be a 90-line `@keyframes` blob in a raw stylesheet. `dwkeyframes!`
+//! emits the at-rule *and* — where `#[animation(...)]` is given — a
+//! compile-time-checked `animate-*` class, and only injects a rule if something
+//! actually uses it.
+//!
+//! Where an animation needs a delay computed at runtime, use the handle
+//! directly: `format!("{FADE_UP_KEYFRAMES} 600ms {}ms ease-out both", i * 60)`.
+//! Formatting the handle registers the rule, so the shorthand can never point at
+//! a keyframe that was never injected.
+
+use dwind_macros::dwkeyframes;
+
+dwkeyframes! {
+ #![prefix = "dw"]
+
+ /// Terminal cursor blink on the wordmark.
+ #[animation("1.2s step-end infinite")]
+ cursor_blink {
+ "0%, 49%" => "opacity: 1;",
+ "50%, 100%" => "opacity: 0;",
+ }
+
+ /// The workhorse entrance. Used with computed delays, so no class.
+ fade_up {
+ "from" => "opacity: 0; transform: translateY(14px);",
+ "to" => "opacity: 1; transform: translateY(0);",
+ }
+
+ /// Slow drift for the background colour field.
+ aurora_a {
+ "0%, 100%" => "transform: translate3d(0, 0, 0) scale(1);",
+ "33%" => "transform: translate3d(6%, -8%, 0) scale(1.15);",
+ "66%" => "transform: translate3d(-5%, 5%, 0) scale(0.95);",
+ }
+
+ aurora_b {
+ "0%, 100%" => "transform: translate3d(0, 0, 0) scale(1.05);",
+ "50%" => "transform: translate3d(-8%, 6%, 0) scale(0.9);",
+ }
+
+ /// Sweeps the gold sheen across a gradient headline.
+ #[animation("7s linear infinite")]
+ sheen {
+ "0%" => "background-position: 0% 50%;",
+ "100%" => "background-position: 200% 50%;",
+ }
+
+ /// Headline words ride in one at a time; the delay is per-word.
+ word_in {
+ "from" => "opacity: 0; transform: translateY(0.7em) rotate(2deg);",
+ "to" => "opacity: 1; transform: translateY(0) rotate(0deg);",
+ }
+
+ /// The utility-class ticker. The track is rendered twice, so -50% loops.
+ #[animation("42s linear infinite")]
+ marquee {
+ "from" => "transform: translate3d(0, 0, 0);",
+ "to" => "transform: translate3d(-50%, 0, 0);",
+ }
+
+ #[animation("240ms cubic-bezier(0.16, 1, 0.3, 1) both")]
+ palette_in {
+ "from" => "opacity: 0; transform: translateY(-12px) scale(0.98);",
+ "to" => "opacity: 1; transform: translateY(0) scale(1);",
+ }
+
+ #[animation("180ms ease-out both")]
+ scrim_in {
+ "from" => "opacity: 0;",
+ "to" => "opacity: 1;",
+ }
+
+ #[animation("420ms cubic-bezier(0.16, 1, 0.3, 1) both")]
+ route_in {
+ "from" => "opacity: 0; transform: translateY(10px);",
+ "to" => "opacity: 1; transform: translateY(0);",
+ }
+
+ /// Expanding ring behind a status dot.
+ #[animation("2.2s ease-out infinite")]
+ pulse_ring {
+ "0%" => "box-shadow: 0 0 0 0 rgba(213, 182, 95, 0.35);",
+ "70%" => "box-shadow: 0 0 0 12px rgba(213, 182, 95, 0);",
+ "100%" => "box-shadow: 0 0 0 0 rgba(213, 182, 95, 0);",
+ }
+}
diff --git a/examples/webpage/src/lib.rs b/examples/webpage/src/lib.rs
index ae57bcb..611d69a 100644
--- a/examples/webpage/src/lib.rs
+++ b/examples/webpage/src/lib.rs
@@ -1,9 +1,9 @@
mod fx;
+mod keyframes;
mod pages;
mod palette;
mod reveal;
mod router;
-mod styles;
#[macro_use]
extern crate log;
@@ -15,6 +15,7 @@ extern crate dominator;
extern crate dwui;
use crate::fx::magnetic;
+use crate::keyframes::*;
use crate::pages::components_page::components_page;
use crate::pages::docs::doc_main::doc_main_view;
use crate::pages::docs::doc_sidebar::doc_sidebar;
@@ -23,7 +24,6 @@ use crate::pages::dwind_examples::dwind_examples_page;
use crate::pages::home::home_page;
use crate::palette::Palette;
use crate::router::make_app_router;
-use crate::styles::APP_STYLES;
use dominator::routing::go_to_url;
use dominator::{body, events, Dom};
use dwind::prelude::*;
@@ -49,7 +49,6 @@ fn main_view() -> Dom {
&DWIND_COLORS["woodsmoke"],
&DWIND_COLORS["red"],
)));
- dominator::stylesheet_raw(APP_STYLES);
let palette = palette::global();
let page = make_app_router().signal().broadcast();
@@ -59,10 +58,10 @@ fn main_view() -> Dom {
let scrolled = Mutable::new(0.0f64);
html!("div", {
- .class("dw-scrollbar")
+ .apply(crate::fx::slim_scrollbar)
.dwclass!("text-woodsmoke-100 bg-woodsmoke-950")
.dwclass!("h-full overflow-y-auto overflow-x-hidden")
- .style("position", "relative")
+ .dwclass!("relative")
.apply(palette.shortcuts())
.with_node!(element => {
.event(clone!(scrolled => move |_: events::Scroll| {
@@ -74,11 +73,11 @@ fn main_view() -> Dom {
.child(scroll_progress(scrolled.clone()))
.child(top_nav(&palette, page.signal()))
.child(html!("main", {
- .style("position", "relative")
+ .dwclass!("relative")
.style("z-index", "1")
.child_signal(page.signal().map(|page| {
Some(html!("div", {
- .class("dw-route")
+ .dwclass!("animate-route-in")
.after_inserted(|_| scroll_to_top())
.child(match page {
DocPage::Home => home_page(),
@@ -105,19 +104,19 @@ fn scroll_to_top() {
fn scroll_progress(scrolled: Mutable) -> Dom {
html!("div", {
.attr("aria-hidden", "true")
- .style("position", "fixed")
+ .dwclass!("fixed")
.style("top", "0")
.style("left", "0")
.style("right", "0")
.style("height", "2px")
.style("z-index", "60")
- .style("pointer-events", "none")
+ .dwclass!("pointer-events-none")
.child(html!("div", {
.style("height", "100%")
.style("background", "linear-gradient(90deg, #A88735, #D5B65F 45%, #FFF3CF)")
.style("box-shadow", "0 0 12px rgba(213, 182, 95, 0.6)")
.style("transform-origin", "left center")
- .style("will-change", "transform")
+ .dwclass!("will-change-transform")
.style_signal("transform", scrolled.signal().map(|p| {
format!("scaleX({:.4})", p.max(0.001))
}))
@@ -174,7 +173,7 @@ fn pager_link(page: Option, hint: &str, left: bool) -> Dom {
html!("button", {
.attr("type", "button")
- .class("dw-glass")
+ .apply(crate::fx::glass)
.dwclass!("flex flex-col gap-1 rounded-lg border border-woodsmoke-800 p-4 grow cursor-pointer")
.dwclass!("hover:border-candlelight-700 transition-all")
.apply(fx::spotlight)
@@ -184,7 +183,7 @@ fn pager_link(page: Option, hint: &str, left: bool) -> Dom {
dwclass!(b, "text-right align-items-end")
})
.style("color", "inherit")
- .style("font", "inherit")
+ .dwclass!("font-inherit")
.child(html!("span", {
.class("font-code")
.dwclass!("text-xs text-woodsmoke-500")
@@ -218,7 +217,7 @@ fn top_nav(
.attr("href", "#/")
.class("font-code")
.dwclass!("flex align-items-center gap-1 text-l font-bold text-woodsmoke-50 cursor-pointer")
- .style("text-decoration", "none")
+ .dwclass!("no-underline")
.apply(magnetic(5.0))
.child(html!("span", {
.dwclass!("text-candlelight-400")
@@ -227,7 +226,7 @@ fn top_nav(
.child(html!("span", { .text("dwind") }))
.child(html!("span", {
.dwclass!("text-candlelight-400")
- .style("animation", "dwind-cursor-blink 1.2s step-end infinite")
+ .dwclass!("animate-cursor-blink")
.text("_")
}))
}))
@@ -261,7 +260,7 @@ fn palette_trigger(palette: &Palette) -> Dom {
.dwclass!("text-woodsmoke-400 hover:text-candlelight-300 hover:border-candlelight-700")
.dwclass!("@ Dom {
.attr("rel", "noopener")
.dwclass!("cursor-pointer transition-colors select-none")
.dwclass!("text-woodsmoke-300 hover:text-candlelight-300")
- .style("text-decoration", "none")
+ .dwclass!("no-underline")
.text(label)
.event(move |_: events::Click| {
window()
@@ -331,7 +330,7 @@ fn nav_external(label: &str, url: &str) -> Dom {
fn footer() -> Dom {
html!("footer", {
.dwclass!("border-t border-woodsmoke-800 w-full m-t-20")
- .style("position", "relative")
+ .dwclass!("relative")
.style("z-index", "1")
.child(html!("div", {
.dwclass!("m-x-auto max-w-6xl p-l-4 p-r-4 p-t-12 p-b-12")
@@ -386,7 +385,7 @@ fn footer_column(title: &str, links: Vec<(&str, &str)>) -> Dom {
.attr("href", &href)
.apply_if(external, |b| b.attr("target", "_blank").attr("rel", "noopener"))
.dwclass!("text-woodsmoke-300 hover:text-candlelight-300 text-sm transition-colors cursor-pointer")
- .style("text-decoration", "none")
+ .dwclass!("no-underline")
.text(label)
})
}))
diff --git a/examples/webpage/src/pages/docs/code_widget.rs b/examples/webpage/src/pages/docs/code_widget.rs
index 52799e5..4f87d77 100644
--- a/examples/webpage/src/pages/docs/code_widget.rs
+++ b/examples/webpage/src/pages/docs/code_widget.rs
@@ -18,7 +18,7 @@ pub fn code(example_map: &BTreeMap) -> Dom {
.class("font-code")
.dwclass!("flex flex-row align-items-center gap-2 w-full p-l-4 p-r-4 h-10 cursor-pointer text-left text-sm")
.dwclass!("bg-transparent border-none text-woodsmoke-400 hover:text-candlelight-300 transition-colors")
- .style("outline", "none")
+ .dwclass!("outline-hidden")
.child(html!("span", {
.dwclass!("inline-block transition-transform")
.style_signal("transform", expanded.signal().map(|v| {
diff --git a/examples/webpage/src/pages/docs/doc_pages/animations.rs b/examples/webpage/src/pages/docs/doc_pages/animations.rs
index f7ab6f9..84e223a 100644
--- a/examples/webpage/src/pages/docs/doc_pages/animations.rs
+++ b/examples/webpage/src/pages/docs/doc_pages/animations.rs
@@ -19,6 +19,59 @@ pub fn animation_page() -> Dom {
.child(doc_page_sub_header("Spinning"))
.child(example_box(animation_examples(), false))
.child(code(&ANIMATION_EXAMPLES_EXAMPLE_HTML_MAP))
+
+ .child(doc_page_sub_header("Your own keyframes"))
+ .child(html!("p", {
+ .dwclass!("text-woodsmoke-400 leading-relaxed m-0")
+ .text("A utility class is a single declaration block, so a @keyframes can never be one. \
+ Declare them with dwkeyframes! instead: it emits the at-rule and, when you give it \
+ an #[animation(...)], a matching animate-* class that rustc still checks. The rule \
+ is injected the first time something uses it, and never twice.")
+ }))
+ .child(html!("pre", {
+ .class("font-code")
+ .dwclass!("text-sm p-4 m-0 m-t-4 rounded-lg border border-woodsmoke-800 text-woodsmoke-200 overflow-x-auto")
+ .dwclass!("[background:rgba(2, 2, 3, 0.7)]")
+ .text(r#"dwkeyframes! {
+ #[animation("900ms cubic-bezier(0.16, 1, 0.3, 1) both")]
+ fade_up {
+ "from" => "opacity: 0; transform: translateY(14px);",
+ "to" => "opacity: 1; transform: translateY(0);",
+ }
+}
+
+html!("div", { .dwclass!("animate-fade-up") })"#)
+ }))
+ .child(html!("p", {
+ .dwclass!("text-woodsmoke-400 leading-relaxed m-t-4 m-b-0")
+ .text("Every CSS fragment is a string literal, because Rust's lexer splits 0%, --sx and .35 \
+ in ways that do not survive a round trip through the token stream.")
+ }))
+ .child(html!("p", {
+ .dwclass!("text-woodsmoke-400 leading-relaxed m-t-4 m-b-0")
+ .text("When the shorthand has to be built at runtime — a per-item delay, say — use the handle \
+ directly. Formatting it registers the rule, so the animation can never point at a \
+ keyframe that was never injected:")
+ }))
+ .child(html!("pre", {
+ .class("font-code")
+ .dwclass!("text-sm p-4 m-0 m-t-2 rounded-lg border border-woodsmoke-800 text-woodsmoke-200 overflow-x-auto")
+ .dwclass!("[background:rgba(2, 2, 3, 0.7)]")
+ .text(".style(\"animation\", &format!(\"{FADE_UP_KEYFRAMES} 600ms {}ms ease-out both\", i * 60))")
+ }))
+
+ .child(doc_page_sub_header("Reduced motion"))
+ .child(html!("p", {
+ .dwclass!("text-woodsmoke-400 leading-relaxed m-0")
+ .text("Motion preferences are just another media query, so the @(( )) conditional covers them \
+ with no extra machinery:")
+ }))
+ .child(html!("pre", {
+ .class("font-code")
+ .dwclass!("text-sm p-4 m-0 m-t-2 rounded-lg border border-woodsmoke-800 text-woodsmoke-200 overflow-x-auto")
+ .dwclass!("[background:rgba(2, 2, 3, 0.7)]")
+ .text("dwclass!(\"animate-spin @((prefers-reduced-motion: reduce)):animate-none\")")
+ }))
})
}
diff --git a/examples/webpage/src/pages/docs/doc_pages/doc_page.rs b/examples/webpage/src/pages/docs/doc_pages/doc_page.rs
index 94a50a7..cc5ba13 100644
--- a/examples/webpage/src/pages/docs/doc_pages/doc_page.rs
+++ b/examples/webpage/src/pages/docs/doc_pages/doc_page.rs
@@ -19,7 +19,7 @@ pub fn doc_page_title(title: &str) -> Dom {
.child(html!("h1", {
.class("font-display")
.dwclass!("@sm:text-5xl @ Dom {
.class("font-code")
.dwclass!("p-l-4 p-r-4 p-t-2 p-b-2 text-sm rounded-md border border-woodsmoke-800 cursor-pointer transition-colors")
.dwclass!("text-woodsmoke-300 hover:text-candlelight-300 hover:border-candlelight-700")
- .style("text-decoration", "none")
+ .dwclass!("no-underline")
.text(label)
.event(move |_: events::Click| {
go_to_url(&href);
diff --git a/examples/webpage/src/pages/docs/doc_pages/pseudoclass_themes.rs b/examples/webpage/src/pages/docs/doc_pages/pseudoclass_themes.rs
index 1430186..8891261 100644
--- a/examples/webpage/src/pages/docs/doc_pages/pseudoclass_themes.rs
+++ b/examples/webpage/src/pages/docs/doc_pages/pseudoclass_themes.rs
@@ -28,6 +28,78 @@ pub fn pseudo_class_themes() -> Dom {
}))
.child(example_box(variants(), false))
.child(code(&VARIANTS_EXAMPLE_HTML_MAP))
+
+ // pseudo-elements
+ .child(doc_page_title("Pseudo Elements"))
+ .child(html!("p", {
+ .dwclass!("text-woodsmoke-300 leading-relaxed m-t-4 m-b-2")
+ .text(r#"::before and ::after are variants like any other. dwind adds the content: "" they
+ need in order to render, so a utility is enough on its own. It goes through a
+ --dw-content custom property, so composing several before: utilities keeps whichever
+ content you wrote rather than the last one winning."#)
+ }))
+ .child(example_box(pseudo_elements(), false))
+ .child(code(&PSEUDO_ELEMENTS_EXAMPLE_HTML_MAP))
+
+ // arbitrary declarations
+ .child(doc_page_title("Arbitrary Declarations"))
+ .child(html!("p", {
+ .dwclass!("text-woodsmoke-300 leading-relaxed m-t-4 m-b-2")
+ .text(r#"When a property has no utility — a vendor-prefixed mask, a custom property, a
+ one-off gradient — write the declaration inline in square brackets. It is unambiguous
+ against the variant syntax because a variant's ] is always followed by a colon."#)
+ }))
+ .child(html!("p", {
+ .dwclass!("text-woodsmoke-400 leading-relaxed m-0 m-b-2")
+ .text("Values pass through untouched — spaces are fine inside the brackets, and so is \
+ any character, so [content:'→'] works. Modifiers go first: hover:[color:red], \
+ not [color:red]:hover.")
+ }))
+ .child(example_box(arbitrary_declarations(), false))
+ .child(code(&ARBITRARY_DECLARATIONS_EXAMPLE_HTML_MAP))
+ })
+}
+
+#[example_html(themes = ["base16-ocean.dark", "base16-ocean.light"])]
+fn pseudo_elements() -> Dom {
+ html!("div", {
+ .dwclass!("flex flex-row flex-wrap gap-6 justify-center w-full p-4")
+ // A decorative corner notch, drawn entirely by ::before.
+ .child(html!("div", {
+ .dwclass!("relative rounded-lg border border-woodsmoke-700 p-6 text-woodsmoke-200")
+ .dwclass!("[&::before]:absolute [&::before]:[top:-6px] [&::before]:[left:-6px]")
+ .dwclass!("[&::before]:w-4 [&::before]:h-4 [&::before]:rounded-full")
+ .dwclass!("[&::before]:[background:#D5B65F]")
+ .text("[&::before] corner dot")
+ }))
+ // The `before:` shorthand, with an explicit content override.
+ .child(html!("div", {
+ .dwclass!("relative rounded-lg border border-woodsmoke-700 p-6 text-woodsmoke-200")
+ .dwclass!("before:[content:'→'] before:m-r-2 before:text-candlelight-400")
+ .text("before: shorthand")
+ }))
+ })
+}
+
+#[example_html(themes = ["base16-ocean.dark", "base16-ocean.light"])]
+fn arbitrary_declarations() -> Dom {
+ html!("div", {
+ .dwclass!("flex flex-row flex-wrap gap-6 justify-center w-full p-4")
+ .child(html!("div", {
+ .dwclass!("rounded-lg p-6 text-woodsmoke-950 font-bold")
+ .dwclass!("[background:conic-gradient(from 210deg, #D5B65F, #5FB0D5, #D5B65F)]")
+ .text("conic-gradient")
+ }))
+ .child(html!("div", {
+ .dwclass!("rounded-lg border border-woodsmoke-700 p-6 text-woodsmoke-200")
+ .dwclass!("[writing-mode:vertical-rl] [letter-spacing:0.2em]")
+ .text("vertical-rl")
+ }))
+ .child(html!("div", {
+ .dwclass!("rounded-lg border border-woodsmoke-700 p-6")
+ .dwclass!("[--accent:#75D55F] [color:var(--accent)] [box-shadow:0 0 0 1px var(--accent)]")
+ .text("--accent custom property")
+ }))
})
}
diff --git a/examples/webpage/src/pages/docs/doc_pages/shadows.rs b/examples/webpage/src/pages/docs/doc_pages/shadows.rs
index 38d8da0..5b692b5 100644
--- a/examples/webpage/src/pages/docs/doc_pages/shadows.rs
+++ b/examples/webpage/src/pages/docs/doc_pages/shadows.rs
@@ -62,7 +62,7 @@ fn shadows_example() -> Dom {
.child(html!("button", {
.dwclass!("w-28 h-16 rounded-lg bg-woodsmoke-800 border-none cursor-pointer text-woodsmoke-200")
.dwclass!("focus-visible:ring-2 ring-picton-blue-400")
- .style("outline", "none")
+ .dwclass!("outline-hidden")
.text("tab to me")
}))
})
diff --git a/examples/webpage/src/pages/docs/doc_sidebar.rs b/examples/webpage/src/pages/docs/doc_sidebar.rs
index 5a0630c..131baf5 100644
--- a/examples/webpage/src/pages/docs/doc_sidebar.rs
+++ b/examples/webpage/src/pages/docs/doc_sidebar.rs
@@ -84,7 +84,7 @@ fn sidebar_search() -> Dom {
.dwclass!("border border-woodsmoke-800 rounded-md p-l-3 p-r-2 p-t-2 p-b-2 transition-all")
.dwclass!("text-woodsmoke-500 hover:text-candlelight-300 hover:border-candlelight-700")
.style("background", "rgba(18, 18, 21, 0.6)")
- .style("font", "inherit")
+ .dwclass!("font-inherit")
.style("font-size", "0.72rem")
.child(html!("span", { .text("search docs…") }))
.child(html!("kbd", {
@@ -106,12 +106,12 @@ pub fn doc_sidebar_inline(
.attr("aria-label", "Documentation")
.dwclass!("w-52 m-l-0 text-woodsmoke-50 flex-none flex flex-col gap-6 p-t-2")
// Rides along with the reader instead of scrolling off the top.
- .style("position", "sticky")
+ .dwclass!("sticky")
.style("top", "5.5rem")
.style("align-self", "flex-start")
.style("max-height", "calc(100vh - 8rem)")
- .style("overflow-y", "auto")
- .class("dw-scrollbar")
+ .dwclass!("overflow-y-auto")
+ .apply(crate::fx::slim_scrollbar)
.child(sidebar_search())
.children(doc_sections.into_iter().map(clone!(goto => move |section| {
let section_cloned = section.clone();
diff --git a/examples/webpage/src/pages/docs/example_box.rs b/examples/webpage/src/pages/docs/example_box.rs
index b9643f7..e06638b 100644
--- a/examples/webpage/src/pages/docs/example_box.rs
+++ b/examples/webpage/src/pages/docs/example_box.rs
@@ -13,7 +13,7 @@ pub fn example_box(child: Dom, resizeable: bool) -> Dom {
let dragging = Mutable::new(false);
html!("div", {
- .class("dw-glass")
+ .apply(crate::fx::glass)
.dwclass!("m-t-6 rounded-lg border border-woodsmoke-800 overflow-hidden w-full")
.apply(crate::fx::spotlight)
// header bar
diff --git a/examples/webpage/src/pages/dwind_examples.rs b/examples/webpage/src/pages/dwind_examples.rs
index 09c4cf1..05b3c14 100644
--- a/examples/webpage/src/pages/dwind_examples.rs
+++ b/examples/webpage/src/pages/dwind_examples.rs
@@ -325,7 +325,7 @@ fn variant_zebra_list() -> Dom {
.dwclass!("[& > *]:p-3 [& > *]:text-sm [& > *]:text-woodsmoke-200")
.dwclass!("[& > *:nth-child(odd)]:bg-woodsmoke-900 [& > *:nth-child(even)]:bg-woodsmoke-800")
.dwclass!("[& > *]:nth-child(3):text-candlelight-300")
- .style("list-style", "none")
+ .dwclass!("list-none")
.children([
html!("li", { .text("zebra striping from the parent") }),
html!("li", { .text("no classes on the children") }),
@@ -353,7 +353,7 @@ fn pricing_card() -> Dom {
}))
.child(html!("ul", {
.dwclass!("flex flex-col gap-2 p-4 m-0 text-sm text-woodsmoke-300")
- .style("list-style", "none")
+ .dwclass!("list-none")
.children([
"✓ unlimited projects",
"✓ compile-time styling",
diff --git a/examples/webpage/src/pages/home.rs b/examples/webpage/src/pages/home.rs
index a5f2f6e..5967555 100644
--- a/examples/webpage/src/pages/home.rs
+++ b/examples/webpage/src/pages/home.rs
@@ -1,4 +1,5 @@
use crate::fx::{self, kinetic_headline, magnetic, spotlight, spotlight_tilt};
+use crate::keyframes::*;
use crate::pages::signal_lab::signal_lab;
use crate::reveal::reveal_on_scroll;
use dominator::routing::go_to_url;
@@ -27,7 +28,7 @@ pub fn home_page() -> Dom {
fn hero() -> Dom {
html!("section", {
.dwclass!("w-full overflow-hidden")
- .style("position", "relative")
+ .dwclass!("relative")
.child(fx::blueprint_grid(
"radial-gradient(ellipse 90% 70% at 50% 0%, black 30%, transparent 75%)",
))
@@ -35,37 +36,37 @@ fn hero() -> Dom {
.dwclass!("m-x-auto max-w-6xl p-l-4 p-r-4 p-t-20 p-b-10")
// @md is 1280px in dwind — the hero goes side-by-side from there up.
.dwclass!("flex @md:flex-row @ Dom {
.class("font-code")
.dwclass!("text-woodsmoke-400 text-sm border border-woodsmoke-800 rounded-md p-l-3 p-r-3 p-t-1 p-b-1 select-all flex-none")
.style("background", "rgba(18, 18, 21, 0.6)")
- .style("white-space", "nowrap")
+ .dwclass!("whitespace-nowrap")
.text("> cargo add dwind dwui")
}))
}))
@@ -106,9 +107,9 @@ fn code_card() -> Dom {
html!("div", {
.dwclass!("flex flex-col flex-none w-full")
.style("max-width", "30rem")
- .style("animation", "dwind-fade-up 900ms 200ms ease-out both")
+ .style("animation", &format!("{FADE_UP_KEYFRAMES} 900ms 200ms ease-out both"))
.child(html!("div", {
- .class("dw-glass")
+ .apply(crate::fx::glass)
.dwclass!("rounded-lg border border-woodsmoke-800 overflow-hidden shadow-2xl")
.apply(spotlight_tilt(4.0))
// window chrome
@@ -243,7 +244,7 @@ pub fn section_header(kicker: &str, title: &str) -> Dom {
.child(html!("h2", {
.class("font-display")
.dwclass!("@sm:text-4xl @ Dom {
fn bento_tile(span_large: bool, children: Vec) -> Dom {
html!("div", {
- .class("dw-glass")
+ .apply(crate::fx::glass)
.dwclass!("rounded-lg border border-woodsmoke-800 p-6 flex flex-col gap-3")
.dwclass!("hover:border-candlelight-700")
.apply(spotlight_tilt(2.5))
@@ -344,7 +345,7 @@ fn bento_tile_themes() -> Dom {
.dwclass!("w-6 h-6 rounded-full border border-woodsmoke-700 transition-all")
.dwclass!("hover:scale-125")
.style("background-color", c)
- .style("animation", &format!("dwind-fade-up 600ms {}ms ease-out both", i * 60))
+ .style("animation", &format!("{FADE_UP_KEYFRAMES} 600ms {}ms ease-out both", i * 60))
})
}))
}),
@@ -464,7 +465,7 @@ fn components_preview() -> Dom {
fn preview_card(label: &str, children: Vec) -> Dom {
html!("div", {
- .class("dw-glass")
+ .apply(crate::fx::glass)
.dwclass!("rounded-lg border border-woodsmoke-800 p-6 flex flex-col gap-5 hover:border-candlelight-700")
.apply(spotlight)
.child(html!("div", {
@@ -483,17 +484,17 @@ fn preview_card(label: &str, children: Vec) -> Dom {
fn final_cta() -> Dom {
html!("section", {
.dwclass!("w-full p-t-20")
- .style("position", "relative")
+ .dwclass!("relative")
.apply(reveal_on_scroll)
.child(html!("div", {
.dwclass!("m-x-auto max-w-3xl p-l-4 p-r-4 flex flex-col gap-6 align-items-center")
.child(html!("h2", {
.class("font-display")
.dwclass!("@sm:text-5xl @) -> Dom {
.class("font-code")
.dwclass!("flex flex-row gap-3 align-items-center")
.dwclass!("text-xs text-woodsmoke-500 border border-woodsmoke-800 rounded-full p-l-3 p-r-3 p-t-1 p-b-1")
- .style("position", "absolute")
+ .dwclass!("absolute")
.style("bottom", "0.9rem")
.style("right", "0.9rem")
.style("background", "rgba(2, 2, 3, 0.7)")
.child(html!("span", {
.dwclass!("w-2 h-2 rounded-full bg-apple-400 flex-none")
- .style("animation", "dwind-pulse-ring 2.2s ease-out infinite")
+ .dwclass!("animate-pulse-ring")
}))
.child(html!("span", {
.dwclass!("text-woodsmoke-400")
@@ -195,7 +196,7 @@ fn controls(
let stacked = stacked.clone();
html!("div", {
- .class("dw-glass")
+ .apply(crate::fx::glass)
.dwclass!("rounded-lg border border-woodsmoke-800 p-6 flex flex-col gap-5")
.apply(fx::spotlight)
.child(html!("div", {
diff --git a/examples/webpage/src/palette.rs b/examples/webpage/src/palette.rs
index e0a90b4..37851dd 100644
--- a/examples/webpage/src/palette.rs
+++ b/examples/webpage/src/palette.rs
@@ -6,6 +6,7 @@
//! single `global_event_preventable`. No component framework, no store, no
//! effect hooks.
+use crate::keyframes::*;
use crate::pages::docs::{doc_sections, DocPage};
use dominator::routing::go_to_url;
use dominator::{events, html, Dom, DomBuilder, EventOptions};
@@ -293,14 +294,13 @@ impl Palette {
.attr("role", "dialog")
.attr("aria-modal", "true")
.attr("aria-label", "Command palette")
- .style("position", "fixed")
- .style("inset", "0")
+ .dwclass!("fixed")
+ .dwclass!("inset-0")
.style("z-index", "9999")
.child(html!("div", {
- .class("dw-palette-scrim")
- .style("position", "absolute")
- .style("inset", "0")
- .style("background", "rgba(2, 2, 3, 0.7)")
+ .dwclass!("absolute inset-0 animate-scrim-in \
+ [background:rgba(2, 2, 3, 0.7)] \
+ [backdrop-filter:blur(6px) saturate(0.7)]")
.event({
let this = this.clone();
move |_: events::Click| this.close()
@@ -310,15 +310,15 @@ impl Palette {
// `transform`, and a fill-mode animation beats an inline style.
.child(html!("div", {
.dwclass!("flex justify-center w-full")
- .style("position", "absolute")
+ .dwclass!("absolute")
.style("top", "14vh")
.style("left", "0")
.style("padding", "0 1rem")
.child(html!("div", {
- .class("dw-palette")
- .dwclass!("rounded-lg overflow-hidden flex flex-col w-full")
- .style("max-width", "38rem")
- .style("background", "rgba(12, 12, 15, 0.86)")
+ .dwclass!("rounded-lg overflow-hidden flex flex-col w-full animate-palette-in")
+ .dwclass!("[max-width:38rem] [background:rgba(12, 12, 15, 0.86)] \
+ [backdrop-filter:blur(20px) saturate(1.4)] \
+ [box-shadow:0 32px 80px -12px rgba(0, 0, 0, 0.85), 0 0 0 1px rgba(213, 182, 95, 0.14), inset 0 1px 0 0 rgba(255, 255, 255, 0.05)]")
.child(this.search_row())
.child(this.results())
.child(this.hint_row())
@@ -338,7 +338,9 @@ impl Palette {
.text("⌘")
}))
.child(html!("input" => web_sys::HtmlInputElement, {
- .class("dw-palette-input")
+ .dwclass!("w-full [background:transparent] [border:none] [outline:none] \
+ [color:#F5F5F6] [font-size:1.05rem] font-inherit \
+ [&::placeholder]:[color:#55555F]")
.attr("type", "text")
.attr("placeholder", "Jump to a page, a utility group, the repo…")
.attr("aria-label", "Search")
@@ -366,7 +368,7 @@ impl Palette {
let this = self.clone();
html!("div", {
- .class("dw-scrollbar")
+ .apply(crate::fx::slim_scrollbar)
.dwclass!("flex flex-col p-2 overflow-y-auto")
.style("max-height", "min(24rem, 50vh)")
.child_signal(map_ref! {
@@ -403,7 +405,7 @@ impl Palette {
.dwclass!("rounded-md p-l-3 p-r-3 p-t-2 p-b-2 border-none cursor-pointer transition-colors")
.style("background", if active { "rgba(213, 182, 95, 0.10)" } else { "transparent" })
.style("color", "inherit")
- .style("font", "inherit")
+ .dwclass!("font-inherit")
.child(html!("span", {
.class("font-code")
.dwclass!("text-xs flex-none w-4")
diff --git a/examples/webpage/src/reveal.rs b/examples/webpage/src/reveal.rs
index d212d87..4bb0335 100644
--- a/examples/webpage/src/reveal.rs
+++ b/examples/webpage/src/reveal.rs
@@ -1,12 +1,17 @@
//! Scroll-triggered progressive reveal.
//!
-//! A single shared `IntersectionObserver` watches elements tagged with the
-//! `reveal-section` class; when one scrolls into view it gains `reveal-in`,
-//! and the CSS in [`crate::APP_KEYFRAMES`] cascades its children up with a
-//! small stagger. Elements are unobserved after revealing, so the effect
-//! plays once.
+//! A single shared `IntersectionObserver` watches tagged elements; when one
+//! scrolls into view it gains `reveal-in`, and the child-selector variants
+//! applied by [`reveal_on_scroll`] cascade its children up with a stagger.
+//! Elements are unobserved after revealing, so the effect plays once.
+//!
+//! The whole cascade — including the parent-state-driven `.reveal-in > *` rules
+//! and the `:nth-child` stagger — is expressed with `dwclass!` variants, so it
+//! needs no stylesheet.
use dominator::DomBuilder;
+use dwind::prelude::*;
+use dwind_macros::dwclass;
use std::cell::RefCell;
use wasm_bindgen::prelude::Closure;
use wasm_bindgen::{JsCast, JsValue, UnwrapThrowExt};
@@ -57,8 +62,32 @@ fn with_observer(f: impl FnOnce(&IntersectionObserver)) {
/// Tags the element for scroll reveal. Apply on a container; its direct
/// children fade up in a staggered cascade when it enters the viewport.
pub fn reveal_on_scroll(builder: DomBuilder) -> DomBuilder {
- builder.class("reveal-section").after_inserted(|element| {
- let element: &Element = element.as_ref();
- with_observer(|observer| observer.observe(element));
- })
+ builder
+ // Resting state: every direct child is down and invisible.
+ .apply(|b| {
+ dwclass!(
+ b,
+ "[& > *]:opacity-0 [& > *]:[transform:translateY(26px)] \
+ [& > *]:[transition:opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), transform 650ms cubic-bezier(0.16, 1, 0.3, 1)]"
+ )
+ })
+ // Stagger. Each child leaves a beat after the one before it.
+ .apply(|b| {
+ dwclass!(
+ b,
+ "[& > *:nth-child(2)]:delay-75 [& > *:nth-child(3)]:delay-150 \
+ [& > *:nth-child(4)]:[transition-delay:210ms] [& > *:nth-child(5)]:[transition-delay:280ms]"
+ )
+ })
+ // Revealed state, switched on by the observer adding `reveal-in`.
+ .apply(|b| {
+ dwclass!(
+ b,
+ "[&.reveal-in > *]:opacity-100 [&.reveal-in > *]:[transform:translateY(0)]"
+ )
+ })
+ .after_inserted(|element| {
+ let element: &Element = element.as_ref();
+ with_observer(|observer| observer.observe(element));
+ })
}
diff --git a/examples/webpage/src/styles.rs b/examples/webpage/src/styles.rs
deleted file mode 100644
index 88b7aef..0000000
--- a/examples/webpage/src/styles.rs
+++ /dev/null
@@ -1,297 +0,0 @@
-//! App-level raw CSS: keyframes and the handful of effects that are cheaper to
-//! express as a stylesheet than as per-node signals (grain, marquee, masks).
-//!
-//! Everything here is decoration. All *state* still flows through signals — see
-//! [`crate::fx`] for the pointer-reactive parts.
-
-pub const APP_STYLES: &str = r#"
-/* ------------------------------------------------------------------ */
-/* keyframes */
-/* ------------------------------------------------------------------ */
-
-@keyframes dwind-cursor-blink {
- 0%, 49% { opacity: 1; }
- 50%, 100% { opacity: 0; }
-}
-
-@keyframes dwind-fade-up {
- from { opacity: 0; transform: translateY(14px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-@keyframes dwind-glow-drift {
- 0%, 100% { transform: translate(0, 0) scale(1); }
- 50% { transform: translate(4%, -6%) scale(1.08); }
-}
-
-/* slow, organic drift for the aurora blobs */
-@keyframes dwind-aurora-a {
- 0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
- 33% { transform: translate3d(6%, -8%, 0) scale(1.15); }
- 66% { transform: translate3d(-5%, 5%, 0) scale(0.95); }
-}
-
-@keyframes dwind-aurora-b {
- 0%, 100% { transform: translate3d(0, 0, 0) scale(1.05); }
- 50% { transform: translate3d(-8%, 6%, 0) scale(0.9); }
-}
-
-/* the sheen that sweeps across gradient headlines */
-@keyframes dwind-sheen {
- 0% { background-position: 0% 50%; }
- 100% { background-position: 200% 50%; }
-}
-
-/* word-by-word headline reveal */
-@keyframes dwind-word-in {
- from { opacity: 0; transform: translateY(0.7em) rotate(2deg); }
- to { opacity: 1; transform: translateY(0) rotate(0deg); }
-}
-
-/* infinite utility-class ticker */
-@keyframes dwind-marquee {
- from { transform: translate3d(0, 0, 0); }
- to { transform: translate3d(-50%, 0, 0); }
-}
-
-/* command palette entrance */
-@keyframes dwind-palette-in {
- from { opacity: 0; transform: translateY(-12px) scale(0.98); }
- to { opacity: 1; transform: translateY(0) scale(1); }
-}
-
-@keyframes dwind-scrim-in {
- from { opacity: 0; }
- to { opacity: 1; }
-}
-
-/* route change transition */
-@keyframes dwind-route-in {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-@keyframes dwind-pulse-ring {
- 0% { box-shadow: 0 0 0 0 rgba(213, 182, 95, 0.35); }
- 70% { box-shadow: 0 0 0 12px rgba(213, 182, 95, 0); }
- 100% { box-shadow: 0 0 0 0 rgba(213, 182, 95, 0); }
-}
-
-/* ------------------------------------------------------------------ */
-/* scroll-triggered progressive reveal (see reveal.rs) */
-/* ------------------------------------------------------------------ */
-
-.reveal-section > * {
- opacity: 0;
- transform: translateY(26px);
- transition:
- opacity 650ms cubic-bezier(0.16, 1, 0.3, 1),
- transform 650ms cubic-bezier(0.16, 1, 0.3, 1);
-}
-
-.reveal-section > *:nth-child(2) { transition-delay: 70ms; }
-.reveal-section > *:nth-child(3) { transition-delay: 140ms; }
-.reveal-section > *:nth-child(4) { transition-delay: 210ms; }
-.reveal-section > *:nth-child(5) { transition-delay: 280ms; }
-
-.reveal-section.reveal-in > * {
- opacity: 1;
- transform: translateY(0);
-}
-
-/* ------------------------------------------------------------------ */
-/* film grain — one fixed overlay for the whole app */
-/* ------------------------------------------------------------------ */
-
-.dw-grain {
- position: fixed;
- inset: 0;
- z-index: 9998;
- pointer-events: none;
- opacity: 0.22;
- mix-blend-mode: overlay;
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
-}
-
-/* ------------------------------------------------------------------ */
-/* gradient text with a slow sheen sweep */
-/* ------------------------------------------------------------------ */
-
-.dw-sheen {
- background-image: linear-gradient(
- 100deg,
- #F0E2B6 0%,
- #D5B65F 18%,
- #FFF8E2 30%,
- #D5B65F 42%,
- #A88735 60%,
- #D5B65F 100%
- );
- background-size: 200% auto;
- -webkit-background-clip: text;
- background-clip: text;
- color: transparent;
- animation: dwind-sheen 7s linear infinite;
-}
-
-/* ------------------------------------------------------------------ */
-/* kinetic headline: each word rides in on its own delay */
-/* ------------------------------------------------------------------ */
-
-.dw-word {
- display: inline-block;
- animation: dwind-word-in 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
-}
-
-/* ------------------------------------------------------------------ */
-/* pointer spotlight cards (coordinates are written by fx.rs signals) */
-/* ------------------------------------------------------------------ */
-
-.dw-spot {
- position: relative;
- isolation: isolate;
- transition: transform 400ms cubic-bezier(0.16, 1, 0.3, 1),
- border-color 300ms ease;
-}
-
-/* the glow itself — a radial gradient parked at --sx/--sy */
-.dw-spot::before {
- content: "";
- position: absolute;
- inset: 0;
- z-index: -1;
- border-radius: inherit;
- opacity: 0;
- transition: opacity 320ms ease;
- background: radial-gradient(
- 22rem circle at var(--sx, 50%) var(--sy, 50%),
- rgba(213, 182, 95, 0.13),
- transparent 62%
- );
-}
-
-.dw-spot[data-hot="1"]::before { opacity: 1; }
-
-/* a thin lit edge that tracks the cursor too */
-.dw-spot::after {
- content: "";
- position: absolute;
- inset: 0;
- z-index: -1;
- border-radius: inherit;
- padding: 1px;
- opacity: 0;
- transition: opacity 320ms ease;
- background: radial-gradient(
- 16rem circle at var(--sx, 50%) var(--sy, 50%),
- rgba(213, 182, 95, 0.55),
- transparent 55%
- );
- -webkit-mask:
- linear-gradient(#000 0 0) content-box,
- linear-gradient(#000 0 0);
- -webkit-mask-composite: xor;
- mask:
- linear-gradient(#000 0 0) content-box,
- linear-gradient(#000 0 0);
- mask-composite: exclude;
-}
-
-.dw-spot[data-hot="1"]::after { opacity: 1; }
-
-/* ------------------------------------------------------------------ */
-/* marquee */
-/* ------------------------------------------------------------------ */
-
-.dw-marquee {
- -webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
- mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
-}
-
-.dw-marquee-track {
- display: flex;
- width: max-content;
- animation: dwind-marquee 42s linear infinite;
-}
-
-.dw-marquee:hover .dw-marquee-track { animation-play-state: paused; }
-
-/* ------------------------------------------------------------------ */
-/* command palette */
-/* ------------------------------------------------------------------ */
-
-.dw-palette-scrim {
- animation: dwind-scrim-in 180ms ease-out both;
- backdrop-filter: blur(6px) saturate(0.7);
-}
-
-.dw-palette {
- animation: dwind-palette-in 240ms cubic-bezier(0.16, 1, 0.3, 1) both;
- backdrop-filter: blur(20px) saturate(1.4);
- box-shadow:
- 0 32px 80px -12px rgba(0, 0, 0, 0.85),
- 0 0 0 1px rgba(213, 182, 95, 0.14),
- inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
-}
-
-.dw-palette-input {
- background: transparent;
- border: none;
- outline: none;
- color: #F5F5F6;
- width: 100%;
- font-size: 1.05rem;
- font-family: inherit;
-}
-
-.dw-palette-input::placeholder { color: #55555F; }
-
-/* ------------------------------------------------------------------ */
-/* glass surfaces */
-/* ------------------------------------------------------------------ */
-
-.dw-glass {
- background: linear-gradient(
- 160deg,
- rgba(28, 28, 33, 0.72) 0%,
- rgba(14, 14, 17, 0.62) 100%
- );
- backdrop-filter: blur(14px) saturate(1.2);
- box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.045);
-}
-
-/* ------------------------------------------------------------------ */
-/* route transition */
-/* ------------------------------------------------------------------ */
-
-.dw-route {
- animation: dwind-route-in 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
-}
-
-/* ------------------------------------------------------------------ */
-/* docs prose */
-/* ------------------------------------------------------------------ */
-
-.dw-scrollbar::-webkit-scrollbar { width: 10px; height: 10px; }
-.dw-scrollbar::-webkit-scrollbar-track { background: transparent; }
-.dw-scrollbar::-webkit-scrollbar-thumb {
- background: #26262C;
- border-radius: 8px;
- border: 3px solid transparent;
- background-clip: content-box;
-}
-.dw-scrollbar::-webkit-scrollbar-thumb:hover { background: #3A3A44; background-clip: content-box; }
-
-/* ------------------------------------------------------------------ */
-/* motion preferences */
-/* ------------------------------------------------------------------ */
-
-@media (prefers-reduced-motion: reduce) {
- .dw-marquee-track,
- .dw-sheen,
- .dw-word { animation: none !important; }
-
- .dw-sheen { color: #D5B65F; }
- .dw-spot { transform: none !important; }
-}
-"#;