Amy Pillow UP | HOME chaos.social Fedi | Atom Feed

Sass Mixins

If you're a fan of sass and react, I think you'll find this interesting.

Let me introduce two npm packages I've written: @strawburster/switch and @strawburster/router.

Table of Contents

@strawburster/switch

Demo

sass-switch.gif

A switch is a stateful layout container which can manipulate elements using powerful templating syntax and standard css transitions or animations.

State

The state that a switch layout keeps track of is a string. All possible switch states must be specified at compile time in order to build the css.

@use 'pkg:@strawburster/switch' as *;

.auth {
    @include switch(auth, 'login', 'register', 'reset-password');
}

In this example, the name of the switch is auth and the state can be either login, register, or reset-password.

It's important that the switch mixin is wrapped in a css selector, like above, because it makes use of the parent selector &.

JS Api

The sass mixin must work in tandem with the React hook useSwitch. That means it must have the same name and possible states.

import React from 'react';
import useSwitch from '@strawburster/switch';

export defaultFunction Auth() {
  const [authSwitch, setAuth] = useSwitch(
    { name: 'auth' },
    'login',
    'register',
    'reset-password',
  );
}

The useSwitch hook returns a switch and a function to set its current state. The switch object should be spread over the top-level element in the switch layout.

<div {...authSwitch} id="auth"></div>

The function returned by useSwitch can be used to change the state of the switch layout in an onclick handler:

<button onClick={setAuth('reset-password')}>Reset Password</button>

The function accepts a new state string and returns a function which changes the state to the new state. If using typescript, the function will only accept valid states specified with useSwitch.

Both the switch object and state-changing function can be renamed to any valid javascript identifier.

PS: Access current state in javascript

The useSwitch hook optionally returns boolean flags for each of the states specified, in the order they were passed in:

const [
  orderSwitch,
  setOrderState,
  A_LA_CARTE,
  APPETIZERS,
  SPECIALS,
  ENTREES,
] = useSwitch(
  { name: 'order' },
  'a-la-carte',
  'appetizers',
  'specials',
  'entrees',
);

Each of these boolean values (A_LA_CARTE, APPETIZERS, SPECIALS, and ENTREES) will be true if that possible state is active, false otherwise.

Template syntax

The main use case for a switch layout is hiding and showing elements based on the current state. Without custom styling in the switch mixin, the default behavior is to add the display: none; pointer-events: none; properties to elements when they don't match the current state.

Setting up the template for elements to match the current state can be done in four ways: direct, exclusion, partial, and partial exclusion. The template uses the attribute data-[name] where [name] is the name of the switch layout. So for the above example, with a name of order, the mixin will look for data-order attributes in the template in order to check whether to display or hide each element.

Direct specification

One or more possible states can be listed in the template without any decoration. If the current state matches one of the listed states, the element will be shown. Otherwise, it will be hidden.

<button onClick={setDemoState('state-a')}>State A</button>
<button onClick={setDemoState('state-b')}>State B</button>
<h3 data-demo="state-a">State A</h3>

In this example, with a switch layout named demo, this heading will only be shown if the current state is state-a.

sass-switch-direct.gif

Exclusionary specification

Elements can specify states for which they don't want to be shown by using the ! operator in the template.

<button onClick={setDemoState('state-a')}>State A</button>
<button onClick={setDemoState('state-b')}>State B</button>
<h3 data-demo="!state-a" >Not state A</h3>

The header here will only be shown on state-b with the exclusionary syntax.

sass-switch-exclusion.gif

Exclusion and direct specification cannot be mixed. If any state is specified with a !, the direct specifications for that element will be ignored.

Partial/Partial exclusionary specification

A switch can use forward slashes to denote that a state is a child of another state:

.demo {
  @include switch.switch(
    demo,
    'state-a',
    'parent',
    'parent/child-1',
    'parent/child-2'
  );
}

All four of these possible states can be used directly or by exclusion, as above, but the neat thing about children states is that they can also be matched using the partial syntax ^.

<button onClick={setDemoState('state-a')}>State A</button>
<button onClick={setDemoState('parent/child-1')}>Child 1</button>
<h3 data-demo="^parent" >Starts with 'parent'</h3>

This header will be shown for all states that start with 'parent' including 'parent/child-1' and 'parent/child-2'.

sass-switch-partial.gif

The partial syntax can be combined with the exclusionary syntax to exclude elements when the state starts with the given string.

<button onClick={setDemoState('state-a')}>State A</button>
<button onClick={setDemoState('parent/child-1')}>Child 1</button>
<h3 data-demo="!^parent" >Does not start with 'parent'</h3>

This div will be shown for all states that don't start with 'parent'.

sass-switch-partial-exclusion.gif

Customize styles

Although the default behavior is to add the display: none; pointer-events: none; properties to any element who's template does not match the current state, that can be overriden in order to provide transitions or animations when the state changes. This can be done by passing in styles to the switch mixin:

.demo {
  [data-demo] {
    transition: opacity 1s;
  }

  @include switch(demo, 'state-a', 'state-b') {
    /**
     * These styles are applied when the current state *does not*
     * match an element's data-demo attribute.
     **/
    opacity: 0;
  }
}

In this example, an opacity transition is applied to all elements which have a data-demo attribute and, when an element's data-demo attribute does not match the current state, it will have an opacity of 0.

sass-switch-transition.gif

You can also specify animations to use by specifying a 'default' animation for all elements, then specifying a 'hiding' animation with the switch mixin.

.demo {
  [data-demo] {
    animation: _fade-in 1s;
  }

  @include switch(demo, 'state-a', 'state-b') {
    animation: _fade-out 1s forwards;
  }
}

@keyframes _fade-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes _fade-out {
  to {
    opacity: 0;
  }
}

Switch child

A specific state can be styled differently from the others using the switchChild mixin:

.demo {
  @include switch(demo, 'state-a', 'state-b');
}

[data-demo] {
  @include switchChild(demo, 'state-b') {
    /**
     * These styles are applied when the 'demo' switch is
     * in 'state-b'.
     **/
    border: 1px solid red;
  }
}

Any number of possible states can be specified. In this example, any data-demo element which is active when the switch is in 'state-b' will have a red border.

sass-switch-child.gif

  • PS: The switch child mixin can create a heirarchy of switches

    Another way to handle heirarchies besides using the forward-slash to denote parent-children relationships is to have multiple switches:

    #order {
      @include switch(order, 'appetizers', 'entrees');
    }
    
    .entrees {
      @include switchChild(order, 'entrees') {
        /**
         * When the 'entrees' state is active, create a new switch
         **/
        @include switch(entrees, 'pasta', 'pizza');
      }
    }
    
    <div {...orderSwitch} id="order">
      <div {...entreesSwitch} data-order="entrees" className="entrees">
        <h3 data-entrees="pasta">Pasta</h3>
        <h3 data-entrees="pizza">Pizza</h3>
      </div>
    </div>
    

@strawburster/router

Demo

The router package is an extension of the switch package which works page-wide and updates the url bar using the 'history' api.

The router object

As opposed to the switch layout, the router can specify possible states for both the JS api and the sass mixin simoultaneously. It does this using a JSON object or a serializable JS object.

The router object has three properties: baseUrl, fallback, and pages.

{
  "baseUrl": "/my-site",
  "fallback": "/404",
  "pages": [
    "/",
    "/page-1",
    "/page-2"
  ]
}

The baseUrl can tell the javascript api how to change the url bar when the route changes. If the site is hosted at domain.com/my-site, the /my-site should be used as the baseUrl. This defaults to / if ommitted.

A fallback page can be specified which tells the router to display a certain page if the one requested does not exist. This page may or may not exist in the pages array.

pages is a list of either strings or Page objects. Usually, you'll want to specify at least a home page /. Page objects have a path property and either a redirectTo or children property.

"pages": [
  "/",
  {
    path: "/page-1",
    children: {
      "/child-page-1",
      "/child-page-2"
    }
  },
  {
    path: "/page-2",
    redirectTo: "/page-1/child-page-2"
  }
]

This object must be imported into sass and passed as an argument to the router mixin. One way to do that is with webpack and @strawburster/sass-json-loader (which I wrote) which uses json2scss-map (which was written by AS Devs) to automatically recognize .json and .js imports within sass files:

// webpack.config.js

module.exports = {
  ...
  module: {
    rules: [
      {
        test: /\.s?[ac]ss$/i,
        use: [
          'style-loader',
          'css-loader',
          'sass-loader',
          '@strawburster/sass-json-laoder',
        ],
      },
    ],
  },
};

Now, @use 'router.json' will use the json2scss-map package to create a sass map variable named $router.

Sass mixin

@use 'pkg:@strawburster/router' as *;
@use 'router.json';

.router {
  @include router($router);
}

The sass mixin takes a router config object and, by default, applies display: none; pointer-events: none; to elements in the template which do not match the current route.

If not using webpack, the router can be specified as a sass map so long as it matches with the object used in the javascript api.

JS Api

import React from 'react';
import useRouter from '@strawburster/router';
import routerConfig from './router.json';

export default function App() {
  const [router, goto, currentRoute] = useRouter(routerConfig);
  return (
    <div className="router" {...router}></div>
  )
}

The useRouter react hook takes a router object and returns a router, a route-changing function, and the current route as a string.

The router object should be spread over the top-level element in the router in order to give css access to the current route stored in javascript, like above. The same top-level element should be selectable in the sass, so I will add a className attribute as well.

The route-changing function returned by useRouter can be used to change the current route in an onclick handler:

<button onClick={goto('/new-page')}>Go to new page</button>

All items returned by the useRouter hook can renamed to any valid javascript identifier.

Template syntax

The template syntax for elements closely resembles the syntax for the switch layout. Each element can be hidden or shown using the absolute, exclusionary, relative, relative exclusionary, partial, partial exclusionary, partial relative, or partial relative exclusionary syntax.

syntax usage description
absolute /route Shown when current route matches any route specified
exclusionary !/route Shown by default, hidden when current route matches any route specified
partial ^/route Shown when current route is either /route or any of its children
partial exclusion !^/route Shown by default, hidden when current route is either /route or any of its children
relative route (no forward slash) Same as absolute, but prepend nearest parent data-route attribute
relative exclusion !route Same as relative, but show element by default and hide it if the derived route matches the current route
partial relative ^route Same as partial, but prepend nearest parent data-route attribute
partial relative exclusion !^route Same as partial exclusion, but prepend nearest parent data-route attribute

Absolute specification

One or more possible routes can be listed in the template without any decoration. If the current route matches one of those listed, the element will be shown. Otherwise, it will be hidden.

<button onClick={goto('/page-1')}>Page 1</button>
<button onClick={goto('/page-2')}>Page 2</button>
<h3 data-route="/page-1">Page 1</h3>
<h3 data-route="/page-2">Page 2</h3>

sass-router-absolute.gif

Exclusionary specificaation

Elements can specify routes for which they don't want to be shown by using the ! operator in the template.

<h3 data-route="!/page-1">Not page 1</h3>
<h3 data-route="!/page-2">Not page 2</h3>

sass-router-exclusion.gif

Exclusionary and absolute specification of routes cannot be mixed. If any state is specified with a !, any absolute routes specified for that element will be ignored.

Partial/Partial exclusionary specification

Matching a route that has children can be done using the ^ syntax:

{
  "pages": [
    "/",
    {
      "path": "/parent",
      "children": [
        "/child"
      ]
    }
  ]
}
<h3 data-route="/parent">Shown only on parent</h3>
<h3 data-route="^/parent">Shown on all children of parent</h3>

sass-router-partial.gif

The partial syntax can be combined with the exclusionary syntax to exclude elements when the current route starts with the given string.

<div data-route="!^/specials"></div>

In this example, the div will be shown for all routes that don't start with /specials.

Relative, relative exclusion, partial relative and partial relative exclusion

If using the partial syntax to display an element on all children routes of a given route, children elements can piggy-back off of the parent data-route attribute by dropping the leading forward slash. This prevents you from having to specify a long route:

<div data-route="^/some/long/route">
  <span data-route="child1">Child 1</span>
  <span data-route="/some/long/route/child1">
    Equivalent, but more verbose
  </span>
</div>

The top-level element in this pattern must always use an absolute partial specification.

The relative syntax behaves like an absolute specification by default, but can be combined with any other syntax:

<div data-route="^/parent">
  <span data-route="^child">
    Display when route begins with /parent/child
  </span>
  <span data-route="!other-child">
    Display when route begins with /parent but does not equal /parent/other-child
  </span>
  <span data-route="!^child">
    Display when route begins with /parent but does not begin with /parent/child
  </span>
</div>

Customize styles

The default behavior is to add the styles display: none; pointer-events: none; to elements who's data-route attribute does not match the current route. That can be overridden in order to provide route transitions by passing styles to the router mixin:

.router {
  [data-route] {
    /**
     * These styles are applied all the time regardless of the
     * current route.
     **/
    transition: width 1s;
    overflow: hidden;
    white-space: nowrap;
    width: 50px;
  }

  @include router($router) {
    /**
     * These styles are applied when the current route *DOES NOT*
     * match the data-route attribute.
     **/
    width: 0;
  }
}

In this example, a width transition is applied to all elements which have a data-route attribute, and, when an element's data-route attribute does not match the current route, it will have a width of 0.

sass-router-transition.gif

You can also specify animations to use by specifying a 'default' animation for all data-route elements, then specifying a 'hiding' animation within the router minix.

.router {
  [data-route] {
    animation: _expand 1s;
    overflow: hidden;
    white-space: nowrap;
  }

  @include router($router) {
    animation: _shrink 1s forwards;
  }
}

@keyframes _expand {
  from {
    width: 0;
  }
  to {
    width: 50px;
  }
}

@keyframes _shrink {
  to {
    width: 0;
  }
}

Route child

A specific state can be styled differently from others using the routeChild mixin:

.router {
  @include router($router);
}

.page-a-indicator {
  color: red;
  @include routeChild('/a') {
    color: green;
  }
}
<span className="page-a-indicator">Is page A?</span>
<span data-route="/a">Page A</span>
<span data-route="/b">Page B</span>

sass-router-child.gif

Created: 2022-10-02

Last modified: 2026-04-26