@jsxui/system
Utilities to configure your Design System for any framework or platform.
Install
yarn add @jsxui/system
npm install @jsxui/system
Get Started
This package ships with a helper function to create a design system configuration that returns context aware utilities.
createSystem
After installing, import the helper function and create your design system configuration:
import { createSystem } from "@jsxui/system"
export const { createProps, theme, tokens } = createSystem({
mediaQueries: {
small: { minWidth: 0 },
medium: { minWidth: 960 },
large: { minWidth: 1280 },
dark: { prefersColorScheme: "dark" },
},
colors: {
primary: { initial: "#0070f3", dark: "#0693d2" },
secondary: { initial: "#ff4081", dark: "#c60055" },
background: { initial: "#fafafa", dark: "#212121" },
foreground: { initial: "#212121", dark: "#fafafa" },
},
})
export type ColorValue = keyof typeof theme.colors
createProps
Use the createProps
helper utility to create your first set of component props. We'll start by creating a set of style props to display different text style variations:
import { createProps, ColorValue, theme } from "system"
const textVariant = createProps({
styles: {
fontSize: (size: number) => size,
color: (color: ColorValue) => ({ color: theme.colors[color] }),
},
})
Notice that we start by defining [styles]. These are functions that will be applied to the variant's props and can return either a single value or an object of multiple values. If you've written Sass before, these are similar to mixins.
Now that we have a few styles defined, we can start to add each variant. We can also define any default props that should be applied to all variants like the text color:
import { createProps, ColorValue } from "system"
const textVariant = createProps({
styles: {
fontSize: (size: number) => size,
color: (color: ColorValue) => ({ color: theme.colors[color] }),
},
defaults: {
color: "foreground",
},
variants: {
heading1: {
as: "h1",
fontSize: { initial: "2rem", medium: "3rem", large: "4rem" },
},
heading2: {
as: "h2",
fontSize: { initial: "1.5rem", medium: "2rem", large: "3rem" },
},
body: {
as: "p",
fontSize: { initial: "1rem", medium: "1.25rem", large: "1.5rem" },
},
},
})
And that's it! We've created our first variant that can be used to style text and display the appropriate element in our UI.
Using with other libraries
This text variant can be used with any library. For an example, let's see how we can use it with Styled Components, a popular CSS-in-JS library:
import type { AttributeProps, StyleProps } from "@jsxui/system"
import styled from "styled-components"
import { textVariant } from "system"
export type TextAttributeProps = AttributeProps<typeof textVariant>
export type TextStyleProps = StyleProps<typeof textVariant>
export const Text = styled.p.attrs<TextAttributeProps>(
(props) => textVariant.getProps(props.variant, props.states).attributes
)<TextStyleProps>((props) => textVariant.getProps(props.variant, props.states).styles)
Now we can use our Text
component in our application with fully typed variants and states:
import { Text } from "system"
function App() {
return (
<Text variant="heading1" color="primary">
Hello World
</Text>
)
}
If you'd like to use CSS properties, use the collectVariants
helper to create global styles:
import { useMemo } from "react"
import { ThemeKey } from "@jsxui/system"
import { collectVariants, theme } from "system"
import { createGlobalStyle, ThemeProvider } from "styled-components"
export function AppProvider() {
const GlobalStyles = useMemo(() => {
return createGlobalStyle(collectVariants())
}, [])
return (
<>
<GlobalStyles />
<App />
</>
)
}
Now variants will reference a global CSS variable. This has the benefit of allowing us to easily change the theme without having to update all of our components and is particularly useful for server-side rendering.
Theming
Most applications need to be able to change the theme of their design system. This is where the theme
object comes in.
Taking our previous example, we can create a theme object that can be used to change the colors of our text. We'll start by creating a Theme
component:
import { ThemeKey } from "@jsxui/system"
import { collectVariants, theme } from "system"
import { createGlobalStyle, ThemeProvider } from "styled-components"
const GlobalStyles = createGlobalStyle(collectVariants)
const ThemeContext = React.createContext<ThemeKey<typeof theme>>(null)
export function AppProvider({ theme }: { theme: ThemeKey<typeof theme> }) {
return (
<>
<GlobalStyles />
<Theme value={theme}>
<App />
</Theme>
</>
)
}
Using the AppProvider
component we can provide the proper CSS properties that can be used by leaf components.
import { AppProvider } from "system"
export function App({ children }) {
return <AppProvider>{children}</AppProvider>
}
To set a specific theme for a tree of components, use the ThemeProvider
component:
import { AppProvider, ThemeProvider } from "system"
export function App() {
return (
<AppProvider>
<Text>Hello World</Text>
{/* Force theme to always be dark regardless of user preference */}
<Theme variant="dark">
<Text>Hello World</Text>
</Theme>
</AppProvider>
)
}
States
Variants can affect more than just the CSS properties. For example, a variant can affect the element type of a component. In our variant above we specified that the body
variant should be rendered as an p
element. What if we wanted to nest our Text
component though? This would render inaccessible markup as we're nesting two block-level elements in one another.
To fix this, we can use the states
object to specify which states a prop value can define. This allows us to swap out the element while in a specific state:
import { createContext } from "react"
import type { AttributeProps, StyleProps } from "@jsxui/system"
import styled from "styled-components"
import { textVariant } from "system"
export type TextAttributeProps = AttributeProps<typeof textVariant>
export type TextStyleProps = StyleProps<typeof textVariant>
export const TextDescendantContext = createContext(false)
export const Text = styled<TextAttributeProps>((props) => {
const isDescendant = React.useContext(TextDescendantContext)
const { as: Element, ...props } = textVariant.getProps(props.variant, {
descendant: isDescendant,
...props.states,
}).attributes
return (
<TextDescendantContext.Provider value={true}>
<Element {...props} />
</TextDescendantContext.Provider>
)
})<TextStyleProps>((props) => textVariant.getProps(props.variant, props.states).styles)
Now this will allow us to render a span
when the Text
component is a descendant of itself giving us proper semantic markup.
Overriding
Each variant can be overriden.
import { textVariant } from "system"
textVariant.override("heading1", {
fontSize: { initial: "2rem", medium: "3rem", large: "4rem" },
})
This is helpful inside of a component system if we want to be able to override the defaults for a tree:
Guides
Button
import type { StyleProps } from "@jsxui/system"
import type { BoxSizeValue, ColorValue } from "system"
import { createProps, mergeVariants, theme } from "system"
import styled from "styled-components"
const colorTransform = (color: ColorValue) => theme.colors[color]
const buttonVariant = createProps({
name: "variant",
states: ["disabled", "pressed", "focused"],
styles: {
backgroundColor: colorTransform,
color: colorTransform,
borderColor: colorTransform,
borderSize: (size: number) => size,
opacity: (opacity: number) => opacity,
},
defaults: {
opacity: { disabled: 0.65 },
variant: "primary",
},
variants: {
primary: {
color: "foreground",
backgroundColor: "interactiveBackgroundPrimary",
},
primaryOutline: {
color: "interactiveForegroundPrimary",
borderColor: "interactiveBorderPrimary",
borderSize: 1,
},
secondary: {
color: "foreground",
backgroundColor: "interactiveBackgroundSecondary",
},
secondaryOutline: {
color: "interactiveForegroundSecondary",
borderColor: "interactiveBorderSecondary",
borderSize: 1,
},
},
})
const sizeVariant = createProps({
name: "size",
styles: {
fontSize: (size: string) => size,
minHeight: (size: number) => theme.boxSizes[size],
spaceAround: (size: number) => {
const value = theme.boxSpacings[size]
return {
paddingLeft: value,
paddingRight: value,
}
},
},
variants: {
small: {
fontSize: "16px",
minHeight: "20px",
spaceAround: "4px",
},
medium: {
fontSize: "20px",
minHeight: "24px",
spaceAround: "8px",
},
large: {
fontSize: "24px",
minHeight: "32px",
spaceAround: "16px",
},
},
})
const variants = mergeVariants(buttonVariant, sizeVariant)
export const Button = styled.button<StyleProps<typeof variants>>((props) =>
variants.getProps(props.variant, { disabled: props.disabled })
)
// Example
function App() {
return (
<>
<Button size={{ initial: "small", medium: "large" }} variant="secondary">
Hello World
</Button>
<Button disabled>Disabled</Button>
</>
)
}
Image
import { createProps } from "system"
const imageVariant = createProps({
styles: {
width: (value: string) => value,
},
})
const Image = styled(({ source, title }) => {
return source.dark ? (
<picture>
<source media="(prefers-color-scheme:dark)" srcset={source.dark} />
<img src={source.default} title={title} />
</picture>
) : (
<img src={source.default} title={title} />
)
})({ display: "block" }, (props) => imageVariant.getProps(props.variant).styles)
// Example
function App() {
return (
<Image
source={{
initial: "light.jpg",
dark: "dark.jpg",
}}
/>
)
}
Future Ideas
Composable Systems
Composable systems allow a whole system to be easily and deterministically overridden.
import { createSystem } from "@jsxui/system"
const system = createSystem({
mediaQueries: {
small: { minWidth: 640 },
medium: { minWidth: 1024 },
large: { minWidth: 1440 },
},
colors: {
background: "#fff",
foreground: "#000",
},
})
const alternateSystem = createSystem(system, {
colors: {
background: "#000",
foreground: "#fff",
},
})
const textProps = alternateSystem.createProps({
transforms: {
color: (size: keyof typeof alternateSystem.theme.colors) => {
return alternateSystem.theme.colors[size]
},
},
defaults: {
variant: "body",
},
variants: {
body: {
color: "foreground",
},
},
})
const Text = styled.span(textVariant.getProps)
function App() {
return <Text>Hello World</Text> // color: #fff
}
Composable Props
Composable props allow prop definitions to be easily and deterministically overridden.
import { createProps } from "system"
const textProps = createProps({
variants: {
heading1: {
fontSize: { initial: "2rem", medium: "3rem", large: "4rem" },
},
},
})
// Only override the font size at the largest breakpoint
const alternateTextProps = createProps(textProps, {
variants: {
heading1: {
fontSize: { large: "6rem" },
},
},
})