Bs-css, layout/appareance properties

Something I’ve not seen much and wanted to share, is how I’ve implemented layout properties in our product with bs-css.

This work was inspired by a react-europe conference that clearly explains the differences that should be made between the two categories of css properties. You can find it on youtube (https://www.youtube.com/watch?v=FNdByml3zr4).

The problem appears when you use css-in-js and create components with well defined APIs and only want to expose a very limited set of properties.

I’ll use a very basic and naive Alert component for example. This is how I use bs-css to define the design of the component:

module Styles = {
  open Css;
  let info = style([backgroundColor(blue)]);
  let alert = style([backgroundColor(red)]);
};

[@react.component]
let make = (~type_, ~text) =>
  <div className={type_ == "alert" ? Styles.alert : Styles.info}> 
    text->React.string
</div>;

When you want maximum composability, it’s better if the component doesn’t define margins, widths, flex properties, etc. It’s especially bad if you definer margins on your root jsx tag, defining spacing is the responsability of the container.

You then have two choices.

You can expose every layout property in your component API:

[@react.component]
let make = (~type_, ~text, ~marginTop, ~marginBottom, ~minWidth, ~maxWidth) => {
  let style = ReactDOMRe.Style.make(~marginTop, ~marginBottom, ~minWidth, ~maxWidth, ());
  <div style className={type_ == "alert" ? Styles.alert : Styles.info}>
    text->React.string
  </div>;
};

but it is very tedious. You can’t do that for every component and you can’t guess which property will be needed by the users of your component.

The other choice is to expose an additional className that will be merged with the internal one:

let make = (~type_, ~text, ~className) => {
  let finalClassName = Css.merge([className, type_ == "alert" ? Styles.alert : Styles.info]);
  <div className=finalClassName>
    text->React.string 
  </div>;
};

But this time you can’t control what is published, and anything can be changed by user.

To solve that problem, what we did is to create a new module that is a subset of Css and only contains layout rules. This module is abstracted to create an incompatible type with the one from Css.

CssLayout interface:

// Subset that only contains Css properties related to layout organisation
type t;

// In the same spirit, a layoutRule type is a subset of the Css.rule type for layout only
type layoutRule;

// Create a layout style, use it like Css.style
let style: list(layoutRule) => t;

// Helper function that join a layout style with a Css style.
let join: (option(t), string) => string;

// Css layout properties, see Css.rei file

let visibility: [< Css.Types.Visibility.t | Css.Types.Cascading.t] => layoutRule;
let position: [< Css.Types.Position.t | Css.Types.Var.t | Css.Types.Cascading.t] => layoutRule;
let top: [< Css.Types.Length.t | Css.Types.Var.t | Css.Types.Cascading.t] => layoutRule;
let left: [< Css.Types.Length.t | Css.Types.Var.t | Css.Types.Cascading.t] => layoutRule;
let bottom: [< Css.Types.Length.t | Css.Types.Var.t | Css.Types.Cascading.t] => layoutRule;
let right: [< Css.Types.Length.t | Css.Types.Var.t | Css.Types.Cascading.t] => layoutRule;
let width: [ Css.Types.Width.t | Css.Types.Percentage.t | Css.Types.Length.t | Css.Types.Cascading.t] => layoutRule;
let minWidth: [ Css.Types.Width.t | Css.Types.Percentage.t | Css.Types.Length.t | Css.Types.Cascading.t] => layoutRule;
let marginTop: [ Css.Types.Length.t | Css.Types.Margin.t] => layoutRule;
let marginBottom: [ Css.Types.Length.t | Css.Types.Margin.t] => layoutRule;
// ...

CssLayout implementation:

type t = string;
type layoutRule = Css.rule;

let style = Css.style;
let join = (layout, className) =>
  switch (layout) {
  | None => className
  | Some(layout') => className ++ " " ++ layout'
  };

let important = Css.important;
let visibility = Css.visibility;
let position = Css.position;
let top = Css.top;
let left = Css.left;
let bottom = Css.bottom;
let right = Css.right;
let width = Css.width;
let minWidth = Css.minWidth;
let marginTop = Css.marginTop;
let marginBottom = Css.marginBottom;
// ...

As you can see, this is just aliases to the Css functions, but put in a module with an abstract type to make them incompatible with the Css.rule type.

With that new module in your hands, you can safely expose a layout poperty in every component you have:

[@react.component]
let make = (~type_, ~text, ~layout=?) => {
  <div className={CssLayout.join(layout, {type_ == "alert" ? Styles.alert : Styles.info})}>
    text->React.string
  </div>;
};

And this is how you would use it:

module Styles = {
  open Css;
  let container = style([display(flexBox)]);
};

module Layouts = {
  open CssLayout;
  let alert = style([minWidth(200->Css.px), marginTop(10->Css.px)]);
};

[@react.component]
let make = () => {
  <div className=Styles.container> 
    <Alert layout=Layouts.alert type_="info" text="info" /> 
    // ...
  </div>;
};

It works really well for us.
I’m interested to hear if you have other solutions.

10 Likes

I agree that className for React components is evil: you cannot about all the possible looks of your component.

As for layout. For one thing, we have components that don’t have any layout properties (just a 100% width), some components that do have some layout (like modals or inputs/buttons), and some components that only provide layout for other components (rows, stacks, paddings). One downside of the latter is obviously that there are more components in the tree and more nodes in the DOM. But it doesn’t bother us so far. Anyway, I think it’s the same approach as yours but taken a bit further: you don’t pass className, you customize some very specific aspects.

Another layer that we have is just a bunch of constants that define all possible paddings, colors, etc. So actually, when you pass something like a gap value to the row, it either directly reuses the type for all possible spacing values or maps to a value of that type. That way, we have our design system in ReScript.

(In fact, things like widths are not part of those constants, maybe because it’s OK for modals or alerts to be of different width, or maybe it’s just an omission.)

I think that having a design system in types and constants also could allow us to create a bunch of utility classes reusing our design system. But so far we’re more or less happy with utility components instead.

<Alert layout=Layouts.alert ... />

Isn’t this a bit tautological? Do you pass other values besides Layouts.alert. Even so, does it makes sense to make Layouts.alert the default value?

It’s not exclusive, we also have components like <HBox>, <VBox>, etc and all design style variables like colors typescale, margins are encoded in modules. But sometimes you need to be more specific depending on the context.

The main advantages imo:

  • it is easier to use for devs and less surprising if all your components share the same ‘layout’ property
  • it’s easier to add functionalities, you can add properties in the Layout rules without impacting any of your component API.

the code is just a sample, I created a specific layout for alert component that’s why I named it ‘alert’.
you could have default layout for components, we have few of them, but layout is very dependent on the context.

1 Like