├── .gitignore ├── Cargo.toml ├── styled ├── Cargo.toml ├── src │ └── lib.rs └── README.md └── styled_macro ├── Cargo.toml ├── src └── lib.rs └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | .cargo 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "3" 3 | 4 | members = ["styled", "styled_macro"] 5 | -------------------------------------------------------------------------------- /styled/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "styled" 3 | version = "0.3.2" 4 | edition = "2024" 5 | description = "Scoped styles for your Leptos components" 6 | license = "APL-1.0" 7 | keywords = ["leptos", "scoped", "styles", "styling", "CSS"] 8 | categories = ["wasm", "web-programming"] 9 | readme = "README.md" 10 | repository = "https://github.com/eboody/styled" 11 | 12 | [dependencies] 13 | stylist = { version = "0.13.0" } 14 | styled_macro = "0.3" 15 | -------------------------------------------------------------------------------- /styled_macro/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "styled_macro" 3 | version = "0.3.2" 4 | edition = "2024" 5 | description = "Scoped styles for your Leptos components" 6 | license = "APL-1.0" 7 | keywords = ["leptos", "scoped", "styles", "styling", "CSS"] 8 | categories = ["wasm", "web-programming"] 9 | readme = "README.md" 10 | repository = "https://github.com/eboody/styled" 11 | 12 | [dependencies] 13 | proc-macro-error = "1.0.4" 14 | proc-macro2 = "1.0.95" 15 | quote = "1.0.40" 16 | 17 | [lib] 18 | proc-macro = true 19 | -------------------------------------------------------------------------------- /styled/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::hash_map::DefaultHasher, 3 | hash::{Hash, Hasher}, 4 | sync::atomic::{AtomicUsize, Ordering}, 5 | }; 6 | 7 | pub use stylist::{Result, Style, style}; 8 | 9 | #[macro_export] 10 | macro_rules! view { 11 | ($styles:expr, $($tokens:tt)*) => {{ 12 | 13 | let style = $styles; 14 | 15 | let $crate::StyleInfo { class_name, style_string } = $crate::get_style_info(style); 16 | 17 | ::leptos::view! { 18 | 19 |
20 | $($tokens)* 21 |
22 | } 23 | }}; 24 | } 25 | 26 | /// Global counter as fallback for unique class generation 27 | static COUNTER: AtomicUsize = AtomicUsize::new(0); 28 | 29 | #[derive(Clone)] 30 | pub struct StyleInfo { 31 | pub class_name: String, 32 | pub style_string: String, 33 | } 34 | 35 | fn generate_callsite_hash(file: &str, line: u32) -> u64 { 36 | let mut hasher = DefaultHasher::new(); 37 | file.hash(&mut hasher); 38 | line.hash(&mut hasher); 39 | COUNTER.fetch_add(1, Ordering::Relaxed).hash(&mut hasher); 40 | hasher.finish() 41 | } 42 | 43 | pub fn get_style_info(styles_result: Result 60 | #remaining 61 | } 62 | } 63 | } 64 | } else { 65 | quote! { 66 | ::leptos::view! { 67 | #remaining 68 | } 69 | } 70 | }; 71 | 72 | expanded.into() 73 | } 74 | -------------------------------------------------------------------------------- /styled/README.md: -------------------------------------------------------------------------------- 1 | # Styled: Easy Styling for Leptos Components 2 | 3 | If you're looking for an easy way to apply scoped styles to your [Leptos](https://github.com/leptos-rs/leptos) components, Styled is the Leptos macro you need. With Styled, you can apply high-level selectors like button or div to specific components, keeping your markup clean and organized. 4 | 5 | ## Installation 6 | 7 | Use cargo add in your project root 8 | 9 | ```bash 10 | cargo add styled stylist 11 | ``` 12 | 13 | 14 | ## Usage 15 | 16 | First create a basic Leptos component. This will serve as the foundation for this little guide. 17 | 18 | ```rust 19 | #[component] 20 | pub fn MyComponent() -> impl IntoView{ 21 | view! { 22 |
"hello"
23 | } 24 | } 25 | ``` 26 | 27 | 28 | Next, import the style macro, powered by an awesome crate called [Stylist](https://github.com/futursolo/stylist-rs), to create your styles. 29 | Just add this to the top of your file. 30 | 31 | ```rust 32 | use styled::style; 33 | ``` 34 | 35 | 36 | You can then use the `style` macro to create a Result containing your styles. Let's modify our component: 37 | 38 | ```rust 39 | #[component] 40 | pub fn MyComponent() -> impl IntoView{ 41 | 42 | let styles = style!( 43 | div { 44 | background-color: red; 45 | color: white; 46 | } 47 | ); 48 | 49 | view! { 50 |
"hello"
51 | } 52 | } 53 | ``` 54 | 55 | Now, let's apply those styles with our `styled::view!` macro! 56 | 57 | ```rust 58 | #[component] 59 | pub fn MyComponent() -> impl IntoView { 60 | 61 | let styles = style!( 62 | div { 63 | background-color: red; 64 | color: white; 65 | } 66 | ); 67 | 68 | styled::view! { 69 | styles, 70 |
"This text should be red with white text."
71 | } 72 | } 73 | ``` 74 | 75 | Now we can define another component that also uses the div CSS selector but it's styles will only apply to the elements inside of it's enclosing `styled::view!` macro. 76 | 77 | ```rust 78 | #[component] 79 | pub fn AnotherComponent() -> impl IntoView { 80 | 81 | // note were using a plain div selector and it wont clash with MyComponent's div style! 82 | let styles = style!( 83 | div { 84 | background-color: blue; 85 | color: gray; 86 | } 87 | ); 88 | 89 | styled::view! { 90 | styles, 91 |
"This text should be blue with gray text."
92 | } 93 | } 94 | ``` 95 | 96 | ## Longer Example 97 | 98 | ```rust 99 | // /src/components/button.rs 100 | 101 | use crate::theme::get_theme; 102 | use leptos::prelude::*; 103 | use styled::style; 104 | 105 | #[derive(PartialEq)] 106 | pub enum Variant { 107 | PRIMARY, 108 | SECONDARY, 109 | ALERT, 110 | DISABLED, 111 | } 112 | 113 | impl Variant { 114 | pub fn is(&self, variant: &Variant) -> bool { 115 | self == variant 116 | } 117 | } 118 | 119 | struct ButtonColors { 120 | text: String, 121 | background: String, 122 | border: String, 123 | } 124 | 125 | fn get_colors(variant: &Variant) -> ButtonColors { 126 | let theme = get_theme().unwrap(); 127 | match variant { 128 | Variant::PRIMARY => ButtonColors { 129 | text: theme.white(), 130 | background: theme.black(), 131 | border: theme.transparent(), 132 | }, 133 | Variant::SECONDARY => ButtonColors { 134 | text: theme.black(), 135 | background: theme.white(), 136 | border: theme.gray.lightest(), 137 | }, 138 | Variant::ALERT => ButtonColors { 139 | text: theme.white(), 140 | background: theme.red(), 141 | border: theme.transparent(), 142 | }, 143 | Variant::DISABLED => ButtonColors { 144 | text: theme.white(), 145 | background: theme.red(), 146 | border: theme.transparent(), 147 | }, 148 | } 149 | } 150 | 151 | #[component] 152 | pub fn Button(variant: Variant) -> impl IntoView { 153 | let disabled = variant.is(&Variant::DISABLED); 154 | 155 | let styles = styles(&variant); 156 | 157 | styled::view! { 158 | styles, 159 | 160 | } 161 | } 162 | 163 | fn styles<'a>(variant: &Variant) -> styled::Result { 164 | let colors = get_colors(variant); 165 | 166 | style!( 167 | button { 168 | color: ${colors.text}; 169 | background-color: ${colors.background}; 170 | border: 1px solid ${colors.border}; 171 | outline: none; 172 | height: 48px; 173 | min-width: 154px; 174 | font-size: 14px; 175 | font-weight: 700; 176 | text-align: center; 177 | box-shadow: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px; 178 | position: relative; 179 | box-sizing: border-box; 180 | vertical-align: middle; 181 | text-align: center; 182 | text-overflow: ellipsis; 183 | text-transform: uppercase; 184 | overflow: hidden; 185 | cursor: pointer; 186 | transition: box-shadow 0.2s; 187 | margin: 10px; 188 | } 189 | 190 | & button:active { 191 | transform: scale(0.99); 192 | } 193 | 194 | 195 | & button::-moz-focus-inner { 196 | border: none; 197 | } 198 | 199 | & button::before { 200 | content: ""; 201 | position: absolute; 202 | top: 0; 203 | bottom: 0; 204 | left: 0; 205 | right: 0; 206 | background-color: rgb(255, 255, 255); 207 | opacity: 0; 208 | transition: opacity 0.2s; 209 | } 210 | 211 | & button::after { 212 | content: ""; 213 | position: absolute; 214 | left: 50%; 215 | top: 50%; 216 | border-radius: 50%; 217 | padding: 50%; 218 | background-color: ${colors.text}; 219 | opacity: 0; 220 | transform: translate(-50%, -50%) scale(1); 221 | transition: opacity 1s, transform 0.5s; 222 | } 223 | 224 | & button:hover, 225 | & button:focus { 226 | box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.2), 0 4px 5px 0 rgba(0, 0, 0, 0.14), 0 1px 10px 0 rgba(0, 0, 0, 0.12); 227 | } 228 | 229 | & button:hover::before { 230 | opacity: 0.08; 231 | } 232 | 233 | & button:hover:focus::before { 234 | opacity: 0.3; 235 | } 236 | 237 | & button:active { 238 | box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12); 239 | } 240 | 241 | & button:active::after { 242 | opacity: 0.32; 243 | transform: translate(-50%, -50%) scale(0); 244 | transition: transform 0s; 245 | } 246 | 247 | & button:disabled { 248 | color: rgba(0, 0, 0, 0.28); 249 | background-color: rgba(0, 0, 0, 0.12); 250 | box-shadow: none; 251 | cursor: initial; 252 | } 253 | 254 | & button:disabled::before { 255 | opacity: 0; 256 | } 257 | 258 | & button:disabled::after { 259 | opacity: 0; 260 | } 261 | 262 | ) 263 | } 264 | ``` 265 | 266 | 267 | ```rust 268 | // /src/theme/mod.rs 269 | use csscolorparser::Color; 270 | 271 | pub fn get_theme() -> Result { 272 | let theme = Theme { 273 | teal: Colors { 274 | main: Color::from_html("#6FDDDB")?, 275 | darker: Color::from_html("#2BB4B2")?, 276 | lighter: Color::from_html("#7EE1DF")?, 277 | lightest: Color::from_html("#B2EDEC")?, 278 | }, 279 | pink: Colors { 280 | main: Color::from_html("#E93EF5")?, 281 | darker: Color::from_html("#C70BD4")?, 282 | lighter: Color::from_html("#F5A4FA")?, 283 | lightest: Color::from_html("#FCE1FD")?, 284 | }, 285 | green: Colors { 286 | main: Color::from_html("#54D072")?, 287 | darker: Color::from_html("#30AF4F")?, 288 | lighter: Color::from_html("#82DD98")?, 289 | lightest: Color::from_html("#B4EAC1")?, 290 | }, 291 | purple: Colors { 292 | main: Color::from_html("#8C18FB")?, 293 | darker: Color::from_html("#7204DB")?, 294 | lighter: Color::from_html("#B162FC")?, 295 | lightest: Color::from_html("#D0A1FD")?, 296 | }, 297 | yellow: Colors { 298 | main: Color::from_html("#E1E862")?, 299 | darker: Color::from_html("#BAC31D")?, 300 | lighter: Color::from_html("#EFF3AC")?, 301 | lightest: Color::from_html("#FAFBE3")?, 302 | }, 303 | gray: Colors { 304 | main: Color::from_html("#4a4a4a")?, 305 | darker: Color::from_html("#3d3d3d")?, 306 | lighter: Color::from_html("#939393")?, 307 | lightest: Color::from_html("#c4c4c4")?, 308 | }, 309 | red: Color::from_html("#FF5854")?, 310 | black: Color::from_html("#000000")?, 311 | white: Color::from_html("#FFFFFF")?, 312 | transparent: Color::from_html("transparent")?, 313 | }; 314 | 315 | Ok(theme) 316 | } 317 | 318 | pub struct Theme { 319 | pub teal: Colors, 320 | pub pink: Colors, 321 | pub green: Colors, 322 | pub purple: Colors, 323 | pub yellow: Colors, 324 | pub gray: Colors, 325 | pub red: Color, 326 | pub black: Color, 327 | pub white: Color, 328 | pub transparent: Color, 329 | } 330 | 331 | pub struct Colors { 332 | pub main: Color, 333 | pub darker: Color, 334 | pub lighter: Color, 335 | pub lightest: Color, 336 | } 337 | 338 | impl Colors { 339 | pub fn main(&self) -> String { 340 | self.main.to_hex_string() 341 | } 342 | pub fn darker(&self) -> String { 343 | self.darker.to_hex_string() 344 | } 345 | pub fn lighter(&self) -> String { 346 | self.lighter.to_hex_string() 347 | } 348 | pub fn lightest(&self) -> String { 349 | self.lightest.to_hex_string() 350 | } 351 | } 352 | 353 | impl Theme { 354 | pub fn red(&self) -> String { 355 | self.red.to_hex_string() 356 | } 357 | pub fn black(&self) -> String { 358 | self.black.to_hex_string() 359 | } 360 | pub fn white(&self) -> String { 361 | self.white.to_hex_string() 362 | } 363 | pub fn transparent(&self) -> String { 364 | self.transparent.to_hex_string() 365 | } 366 | } 367 | ``` 368 | 369 | 370 | ```rust 371 | // /src/app.rs 372 | 373 | #[component] 374 | fn HomePage() -> impl IntoView { 375 | // note that this is the default view macro 376 | view! { 377 | 161 | } 162 | } 163 | 164 | fn styles<'a>(variant: &Variant) -> styled::Result { 165 | let colors = get_colors(variant); 166 | 167 | style!( 168 | button { 169 | color: ${colors.text}; 170 | background-color: ${colors.background}; 171 | border: 1px solid ${colors.border}; 172 | outline: none; 173 | height: 48px; 174 | min-width: 154px; 175 | font-size: 14px; 176 | font-weight: 700; 177 | text-align: center; 178 | box-shadow: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px; 179 | position: relative; 180 | box-sizing: border-box; 181 | vertical-align: middle; 182 | text-align: center; 183 | text-overflow: ellipsis; 184 | text-transform: uppercase; 185 | overflow: hidden; 186 | cursor: pointer; 187 | transition: box-shadow 0.2s; 188 | margin: 10px; 189 | } 190 | 191 | & button:active { 192 | transform: scale(0.99); 193 | } 194 | 195 | 196 | & button::-moz-focus-inner { 197 | border: none; 198 | } 199 | 200 | & button::before { 201 | content: ""; 202 | position: absolute; 203 | top: 0; 204 | bottom: 0; 205 | left: 0; 206 | right: 0; 207 | background-color: rgb(255, 255, 255); 208 | opacity: 0; 209 | transition: opacity 0.2s; 210 | } 211 | 212 | & button::after { 213 | content: ""; 214 | position: absolute; 215 | left: 50%; 216 | top: 50%; 217 | border-radius: 50%; 218 | padding: 50%; 219 | background-color: ${colors.text}; 220 | opacity: 0; 221 | transform: translate(-50%, -50%) scale(1); 222 | transition: opacity 1s, transform 0.5s; 223 | } 224 | 225 | & button:hover, 226 | & button:focus { 227 | box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.2), 0 4px 5px 0 rgba(0, 0, 0, 0.14), 0 1px 10px 0 rgba(0, 0, 0, 0.12); 228 | } 229 | 230 | & button:hover::before { 231 | opacity: 0.08; 232 | } 233 | 234 | & button:hover:focus::before { 235 | opacity: 0.3; 236 | } 237 | 238 | & button:active { 239 | box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12); 240 | } 241 | 242 | & button:active::after { 243 | opacity: 0.32; 244 | transform: translate(-50%, -50%) scale(0); 245 | transition: transform 0s; 246 | } 247 | 248 | & button:disabled { 249 | color: rgba(0, 0, 0, 0.28); 250 | background-color: rgba(0, 0, 0, 0.12); 251 | box-shadow: none; 252 | cursor: initial; 253 | } 254 | 255 | & button:disabled::before { 256 | opacity: 0; 257 | } 258 | 259 | & button:disabled::after { 260 | opacity: 0; 261 | } 262 | 263 | ) 264 | } 265 | ``` 266 | 267 | 268 | ```rust 269 | // /src/theme/mod.rs 270 | use csscolorparser::Color; 271 | 272 | pub fn get_theme() -> Result { 273 | let theme = Theme { 274 | teal: Colors { 275 | main: Color::from_html("#6FDDDB")?, 276 | darker: Color::from_html("#2BB4B2")?, 277 | lighter: Color::from_html("#7EE1DF")?, 278 | lightest: Color::from_html("#B2EDEC")?, 279 | }, 280 | pink: Colors { 281 | main: Color::from_html("#E93EF5")?, 282 | darker: Color::from_html("#C70BD4")?, 283 | lighter: Color::from_html("#F5A4FA")?, 284 | lightest: Color::from_html("#FCE1FD")?, 285 | }, 286 | green: Colors { 287 | main: Color::from_html("#54D072")?, 288 | darker: Color::from_html("#30AF4F")?, 289 | lighter: Color::from_html("#82DD98")?, 290 | lightest: Color::from_html("#B4EAC1")?, 291 | }, 292 | purple: Colors { 293 | main: Color::from_html("#8C18FB")?, 294 | darker: Color::from_html("#7204DB")?, 295 | lighter: Color::from_html("#B162FC")?, 296 | lightest: Color::from_html("#D0A1FD")?, 297 | }, 298 | yellow: Colors { 299 | main: Color::from_html("#E1E862")?, 300 | darker: Color::from_html("#BAC31D")?, 301 | lighter: Color::from_html("#EFF3AC")?, 302 | lightest: Color::from_html("#FAFBE3")?, 303 | }, 304 | gray: Colors { 305 | main: Color::from_html("#4a4a4a")?, 306 | darker: Color::from_html("#3d3d3d")?, 307 | lighter: Color::from_html("#939393")?, 308 | lightest: Color::from_html("#c4c4c4")?, 309 | }, 310 | red: Color::from_html("#FF5854")?, 311 | black: Color::from_html("#000000")?, 312 | white: Color::from_html("#FFFFFF")?, 313 | transparent: Color::from_html("transparent")?, 314 | }; 315 | 316 | Ok(theme) 317 | } 318 | 319 | pub struct Theme { 320 | pub teal: Colors, 321 | pub pink: Colors, 322 | pub green: Colors, 323 | pub purple: Colors, 324 | pub yellow: Colors, 325 | pub gray: Colors, 326 | pub red: Color, 327 | pub black: Color, 328 | pub white: Color, 329 | pub transparent: Color, 330 | } 331 | 332 | pub struct Colors { 333 | pub main: Color, 334 | pub darker: Color, 335 | pub lighter: Color, 336 | pub lightest: Color, 337 | } 338 | 339 | impl Colors { 340 | pub fn main(&self) -> String { 341 | self.main.to_hex_string() 342 | } 343 | pub fn darker(&self) -> String { 344 | self.darker.to_hex_string() 345 | } 346 | pub fn lighter(&self) -> String { 347 | self.lighter.to_hex_string() 348 | } 349 | pub fn lightest(&self) -> String { 350 | self.lightest.to_hex_string() 351 | } 352 | } 353 | 354 | impl Theme { 355 | pub fn red(&self) -> String { 356 | self.red.to_hex_string() 357 | } 358 | pub fn black(&self) -> String { 359 | self.black.to_hex_string() 360 | } 361 | pub fn white(&self) -> String { 362 | self.white.to_hex_string() 363 | } 364 | pub fn transparent(&self) -> String { 365 | self.transparent.to_hex_string() 366 | } 367 | } 368 | ``` 369 | 370 | 371 | ```rust 372 | // /src/app.rs 373 | 374 | #[component] 375 | fn HomePage() -> impl IntoView { 376 | // note that this is the default view macro 377 | view! { 378 |