I'm calling this idea Styled Elements for the moment but want something catchier. It's got a few weird ideas in it, so bear with me.
Only a HTML element can actually be rendered to the DOM, and thus only a real HTML element can have styling. So, instead of using generic property-passing techniques like className={x}
or {...x}
, I propose something much more intertwined:
import { elem } from 'styled-elem'
const Outer = elem('section', /* styles go here */)
/* this generates a bunch of styles & classnames then creates a simple component
* that wraps React.createElement('section', ...), which is basically the same as
* what the JSX compiler does when you write <section>
*/
export default (props) => (
<Outer /* attributes as normal */>
{ /* children, too */ }
</Outer>
)
That's functionally equivalent to this (which also works)
import { generateClassnames } from 'styled-elem'
export default (props) => (
<section /* attributes here */
className={generateClassnames(/* styles here */)} />
{ /* children here */ }
</section>
)
But to me, the separation of STRUCTURE and STYLE is better served by wrapping up the Element and the Style into a... wait for it... Styled Element.
Default tag is currently 'div', and I've thought about declaring aliases like elem.section
and elem.span
, but didn't really need it building the demo. The current implementation is in Element.js and is pretty simple. But it works well for now!
As far as I've seen, every CSS-in-JS approach opts for simple (maybe-nested) JS objects sticking pretty close to the realities of working with inline styles. I don't think that's good enough to really make styling code as malleable as I want. So instead of doing something like this:
const styles = {
background: 'papyawhip',
color: 'peru',
margin: '4rem'
}
I decided to go with a real Rule and RuleSet class, with a ton of helper methods for constructing them:
import { concat, rules } from 'styled-elem'
const styles = concat(
rules.background('papayawhip'),
rules.color('peru'),
rules.margin('4rem')
)
The advantage is how fluid these objects are. You can deconstruct rules
:
import { concat, elem, rules } from 'styled-elem'
const { background, color, margin } = rules
const styles = concat(
background('papayawhip'),
color('peru'),
margin('4rem')
)
/* or, if you're using elem the concat is implied: */
const Outer = elem('section',
background('papayawhip'),
color('peru'),
margin('4rem')
)
...though that tends to be more trouble than it's worth. What's much better is when you abstract the common rules away:
import { elem } from 'styled-elem'
import { backgrounds, colors, margins } from './styles'
const Outer = elem('section',
backgrounds.light,
colors.dark,
margins.large
)
And then, since we have a proper data model of these styling fragments, we can break the one-to-one mapping of lines of code to lines of CSS:
/* Probably in a shared file */
const darkOnLight = concat(
backgrounds.light,
colors.dark
)
/* Our component doesn't care that
darkOnLight is actually 2 rules */
const Outer = elem('section',
darkOnLight,
margins.large
)
Since you can fluidly generate & combine these styling fragments, you can start to encode more advanced concepts as first-order elements of your design system. I've built a few around the idea of a trait
:
/* Shared file */
import { trait, rules } from 'styled-elem'
export const typography = trait('typography', {
weight: {
light: 300,
bold: 500
},
size: {
'24pt': '1.5rem',
'18pt': '1.125rem',
'16pt': '1rem'
}
}, ({weight, size}) => concat(
rules.fontWeight(weight),
rules.fontSize(size)
))
/* Component file */
const Title = elem('h1',
typography('24pt bold') // {font-weight: 500, font-size: 1.5rem;}
)
const Strapline = elem('h2',
typography('18pt light') // {font-weight: 300, font-size: 1.125rem;}
)
This current trait
implementation has been working pretty ok for me so far you can also provide null defaults and optionally generate rules:
export const typography = trait('typography', {
weight: {
light: 300,
bold: 500,
default: null
},
size: {
'24pt': '1.5rem',
'18pt': '1.125rem',
'16pt': '1rem',
default: null,
}
}, ({weight, size}) => concat(
weight && rules.fontWeight(weight),
size && rules.fontSize(size)
))
// typography('') => {}
// typography('18pt') => {font-size: 1.125rem;}
// typography('bold') => {font-weight: 500;}
// typography('24pt light') => {font-weight: 500, font-size: 1.5rem;}
In fact, that became common enough in my usage that I ended up making the default callback work like that. It works well:
import { rules } from 'styled-elem'
const { fontWeight, fontSize } = rules
export const typography = trait('typography', {
weight: {
light: fontWeight(300),
bold: fontWeight(500),
default: null
},
size: {
'24pt': fontSize('1.5rem'),
'18pt': fontSize('1.125rem'),
'16pt': fontSize('1rem'),
default: null
}
})
I might make default: null
implicit, not sure yet. Maybe a trait
is a special case of a namespace
higher-order-style property, I don't know yet. But this is neat so far.
...at least as much as possible. I'm happy to propose a new abstraction on top of CSS as long as you can fall back to CSS when you need to. I'm talking about tag selectors, pseudo-selectors, descendant selectors, media queries, etc. So, I've defined a nested
and pseudo
function that take an initial argument and a list of Rule
s and understand their place in the world:
import { elem, rules, nested, psuedo } from 'styled-elem'
import { flex } from './styles'
const { flexGrow, borderBottom } = rules
const Nav = elem('nav',
flex('align-center space-around'),
nested('> *',
flexGrow(1)
pseudo('hover',
borderBottom('1px solid')
)
)
)
I had to butcher Aphrodite to get this going but it is going! I'm a big fan of direct-descendant selectors in particular, often you'll have a structure like:
<ProfileImg>
<img src="..." alt="..."/>
</ProfileImg>
I don't like to have to name both the outer div
(assuming you need it for layout purposes) and the inner img
. I'd style it this like:
import { elem, rules, nested } from 'styled-elem'
const ProfileImg = elem(
rules.padding('0.5rem'),
rules.marginRight('0.5rem'),
nested('> img',
rules.height('100%'),
rules.width('auto')
)
)
The good news is, as long as we're generating real CSS (no inline styles) with classnames (the way Aphrodite already does), we can do anything! Except, of course, generate fully-global CSS. But then just write CSS, obviously.
So we do. Rule
s can be nested at any level, there are NestedSelector
and MediaQuery
classes, and they can be nested inside RuleSet
s and have their own RuleSet
s within. It's objects all the way down.
Why no PseudoSelector
class, I hear you (maybe) ask? Well, read on!
This is the big one. Credit has to go to @charliesome for this, too I initially didn't quite get what he was saying, but I'm now 100% on board. Let's go back to the original example:
import { elem, rules } from 'styled-elem'
const Outer = elem('section',
background('papayawhip'),
color('peru'),
margin('4rem')
)
This can instead be written as:
import { elem, css } from 'styled-elem'
const Outer = elem('section', css`
background: papayawhip;
color: peru;
margin: 4rem;
`)
TADA!
Not convinced? Well let's see what we can do. Can we do normal, dumb-as-a-post string concatenation? Of course!
import { bgColor, fgColor, spacingSize } from './styles'
const Outer = elem('section', css`
background: ${bgColor};
color: ${fgColor};
margin: ${bigSpacing};
`)
But that's booooring. Normal string concatenation will do that, and we are waaaaay beyond normal. Instead of replacing simple values, let's replace whole Rule
s:
import { backgrounds, margins } from './styles'
const Outer = elem('section', css`
${backgrounds.light}
color: peru;
${margins.large}
`)
That's right. Instead of using a template string to convert to a string, we're parsing the string parts into Rule
s and combining them with our normal, JS-land stuff with concat
. This let's us do some rad stuff:
const bottomBorderOnHover = css`
&:hover {
${borderBottom('1px solid')}
}
`
const Nav = elem('nav', css`
${flex('align-center space-around')}
> * {
flex-grow: 1;
${bottomBorderOnHover}
}
`)
Note that we're jumping between all of these seamlessly:
Rule
s borderBottom('1px solid')
flex('align-center space-around')
> * {}
&:hover {}
RuleSet
using css
bottomBorderOnHover
And yet it works! See TweetDisplay and FooterActions for the most complex usages I've worked on so far, then see it running at css-components-demo.surge.sh/ (media queries work btw)
Because we have a solid base of style fragments (represented by a RuleSet
) we can basically do what we like. Which is great, because it means that converting a project to use Styled Elements (I really need a better name) might be really possible:
import bootstrap from 'bootstrap'
const Root = elem(`css
${bootstrap}
`)
export default ({children}) => (
<Root>
{children} // All bootstrap styling will apply here
</Root>
)
I haven't done this. I don't really want to try. But, if it worked, it would preface every rule in Bootstrap with the generated class name for Root
(i.e. based off the hash of its contents):
/* generated */
._abc3156 h1 {}
._abc3156 .jumbotron {}
._abc3156 .lead {}
This effectively quarantines global CSS off into its own little space. I'm really interested to explore whether this is possible/desirable.
That's the general idea. And it's mostly just an idea right now.
So that's where I'm at. The code is all here in this repo (not on NPM yet), and it's really simply implemented. The list of things we would need to solve is huge:
At the moment I've got no dynamic styles, nothing adding or removing depending on state. There are a lot of potential ways to do this, but nothing is grabbing me, yet. This is maybe the best way off the top of my head:
const LikeButton = elem(css`
color: ${grey};
> svg {
width: 30px;
height: auto;
}
`).when('clicked', css`
color: ${red};
`)
export default ({state}) => (
<LikeButton clicked={state.isClicked}>
<img src="..."/>
</LikeButton>
)
But I haven't really thought it through. I do like the way that it uses the Element's API to pass properties, just the way you would a more complex component, and it does give an extra reason to use the elem
constructor instead of manually setting the className
property on normal React.DOM elements.
Right now, you could use data-*
attrs or even global classes like -is-clicked
to do the same thing.(Element
will merge className
at the call site with those that are generated):
const LikeButton = elem(css`
/* both these will work */
&[data-clicked] {
color: ${red};
}
&.-is-clicked {
color: ${red};
}
`)
export default ({state}) => (
<LikeButton data-clicked={state.isClicked}
className={state.isClicked ? '-is-clicked' : ''}>
<img src="..."/>
</LikeButton>
)
But I think there might be a better, more natural way to express conditional styles.
Ok so this is the BIG big one. I feel like what I've got so far is a solid porting of CSS information to a native JS structure, but unless than enables something really powerful (like a solid solution to the idea of theming) then I don't know if it's really worth it. You may as well stick with CSS and wait for Custom Properties to land. Or rather, wait for Microsoft to land Custom Properties
BUT DREAM WITH ME, friends. We could do something pretty straightforward like:
const LikeButton = elem(theme => css`
background: ${theme.bg || 'white'};
color: ${theme.fg || 'black'};
${theme.define({fg: 'red'})} /* change theme as it is passed down to children */
`)
The element that it generates could then use React's context
to pull a theme
object out without needing to pass it down the whole tree. Or we could go crazy like:
const LikeButton = elem(css`
background: var(--bg, white);
color: var(--fg, black);
${define(css`
--fg: red; /* passed to children of this element */
`)}
`)
Because we're parsing the CSS, we could parse out the usage of variables, then combine that with the context
trick above, we'd have a pretty good approximation of true CSS variables. But we'd definitely have incompatibilities with the real syntax which might cause more confusion than it's worth.
I have a gut feel that there is a good solution out there, but I haven't found it yet. It probably uses React's context, because I really like React's context.
There's just heaps of stuff in here that's not ready for real use yet. Such as:
&
and those that don't. I used &
because it's familiar from Sass but there are a lot of use cases we'd need to cover (e.g. html.feature-flag & {}
), and Aphrodite is hard enough to deal with already. Speaking of...Element
component wrapper yet. But if there's some fancy stuff to be done, like generating styles on componentWillMount
and caching them or something, Element
seems like a good place to put all that logic. That way, if we get it right, everyone wins without caring about the internals. Party times.elem
, I quite like css
, as names. rules
and the way you have to deconstruct them is a pain. Maybe a babel plugin would help? Though tbh it's so easy to use css
for anything literal and keep rules
for when you're building higher-order-styling components. But traits
API, etc, all up for grabs.Anyway, this feels like a good first step in terms of developer experience (at least for me, the way I write CSS) so... yay?
<3
-Glen.